docs: idiomatic Go patterns from stdlib + Kubernetes with source citations
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Go Patterns
|
||||
|
||||
Idiomatic Go patterns extracted from the [Go standard library](https://github.com/golang/go) and [Kubernetes](https://github.com/kubernetes/kubernetes) source code with verified file:line citations.
|
||||
|
||||
## Structure
|
||||
|
||||
- `patterns/` — Go stdlib patterns (interfaces, errors, concurrency, structs, testing, docs, style, API conventions, packages)
|
||||
- `kubernetes/` — Production-scale patterns from Kubernetes (controllers, informers, workqueues)
|
||||
- `comparison/` — stdlib vs Kubernetes patterns
|
||||
- `smells/` — Anti-patterns and common Go mistakes
|
||||
- `changelog/` — Daily digest of merged PRs
|
||||
|
||||
## Philosophy
|
||||
|
||||
These rules are derived from what the Go source code actually does, not opinions or blog posts. Every pattern cites specific files and line numbers.
|
||||
|
||||
When unsure how to do something in Go, look at how the standard library does it.
|
||||
@@ -0,0 +1,595 @@
|
||||
# Go Concurrency Patterns
|
||||
|
||||
Patterns extracted from the Go standard library source code.
|
||||
|
||||
---
|
||||
|
||||
## 1. sync.Mutex — The Basic Lock
|
||||
|
||||
### Source: `src/sync/mutex.go:18-34`, `src/sync/mutex.go:42-67`
|
||||
|
||||
```go
|
||||
// src/sync/mutex.go:18-34
|
||||
// A Mutex is a mutual exclusion lock.
|
||||
// The zero value for a Mutex is an unlocked mutex.
|
||||
//
|
||||
// A Mutex must not be copied after first use.
|
||||
type Mutex struct {
|
||||
_ noCopy
|
||||
mu isync.Mutex
|
||||
}
|
||||
|
||||
// src/sync/mutex.go:36-39
|
||||
type Locker interface {
|
||||
Lock()
|
||||
Unlock()
|
||||
}
|
||||
|
||||
// src/sync/mutex.go:43-46
|
||||
func (m *Mutex) Lock() {
|
||||
m.mu.Lock()
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
- **Zero value is ready to use** — no constructor needed
|
||||
- **Must not be copied** — enforced by `noCopy` field (go vet detects copies)
|
||||
- **Not associated with a goroutine** — one goroutine can Lock, another can Unlock
|
||||
- **Locker interface** — abstracts over Mutex and RWMutex
|
||||
|
||||
### Idiomatic Usage
|
||||
|
||||
```go
|
||||
var mu sync.Mutex
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
// ... critical section ...
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Copy a mutex
|
||||
type Config struct {
|
||||
mu sync.Mutex
|
||||
data map[string]string
|
||||
}
|
||||
c2 := *c1 // COPIES the mutex — data race
|
||||
|
||||
// DON'T: Forget defer
|
||||
mu.Lock()
|
||||
doSomething() // if this panics, mutex stays locked forever
|
||||
mu.Unlock()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. sync.Once — Exactly-Once Initialization
|
||||
|
||||
### Source: `src/sync/once.go:12-36`, `src/sync/once.go:56-79`
|
||||
|
||||
```go
|
||||
// src/sync/once.go:12-23
|
||||
type Once struct {
|
||||
_ noCopy
|
||||
done atomic.Bool
|
||||
m Mutex
|
||||
}
|
||||
|
||||
// src/sync/once.go:56-63
|
||||
func (o *Once) Do(f func()) {
|
||||
if !o.done.Load() {
|
||||
o.doSlow(f)
|
||||
}
|
||||
}
|
||||
|
||||
// src/sync/once.go:65-72
|
||||
func (o *Once) doSlow(f func()) {
|
||||
o.m.Lock()
|
||||
defer o.m.Unlock()
|
||||
if !o.done.Load() {
|
||||
defer o.done.Store(true)
|
||||
f()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
The implementation reveals a subtle guarantee: **when Do returns, f has finished**. The naive CAS-only approach (documented in comment at line 56-63) would let the second caller return before f completes. The mutex ensures all callers wait.
|
||||
|
||||
The `done` field is first in the struct for hot-path performance on amd64/386 (noted in comment at line 24-27).
|
||||
|
||||
### Idiomatic Usage
|
||||
|
||||
```go
|
||||
var (
|
||||
instance *DB
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func GetDB() *DB {
|
||||
once.Do(func() {
|
||||
instance = connectToDB()
|
||||
})
|
||||
return instance
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Call Do recursively (deadlocks)
|
||||
var once sync.Once
|
||||
once.Do(func() {
|
||||
once.Do(func() { /* deadlock */ })
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. sync.WaitGroup — Waiting for Goroutine Completion
|
||||
|
||||
### Source: `src/sync/waitgroup.go:14-43`, `src/sync/waitgroup.go:236-260`
|
||||
|
||||
```go
|
||||
// src/sync/waitgroup.go:14-43
|
||||
// Typically, a main goroutine will start tasks by calling WaitGroup.Go
|
||||
// and then wait for all tasks to complete by calling WaitGroup.Wait:
|
||||
//
|
||||
// var wg sync.WaitGroup
|
||||
// wg.Go(task1)
|
||||
// wg.Go(task2)
|
||||
// wg.Wait()
|
||||
type WaitGroup struct {
|
||||
noCopy noCopy
|
||||
state atomic.Uint64
|
||||
sema uint32
|
||||
}
|
||||
```
|
||||
|
||||
### Go 1.25+: WaitGroup.Go
|
||||
|
||||
```go
|
||||
// src/sync/waitgroup.go:236-260
|
||||
func (wg *WaitGroup) Go(f func()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if x := recover(); x != nil {
|
||||
// Don't call Done — let panic propagate fatally.
|
||||
panic(x)
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
`WaitGroup.Go` encapsulates the Add/go/Done pattern. Key design: if `f` panics, it re-panics **without** calling Done, preventing the main goroutine from racing to exit before the panic stack trace prints.
|
||||
|
||||
### Classic Pattern (pre-Go 1.25)
|
||||
|
||||
```go
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range items {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
process(item)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Add inside the goroutine (race with Wait)
|
||||
for _, item := range items {
|
||||
go func() {
|
||||
wg.Add(1) // might run after Wait is called!
|
||||
defer wg.Done()
|
||||
process(item)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. sync.Pool — Object Reuse for GC Pressure
|
||||
|
||||
### Source: `src/sync/pool.go:44-63`
|
||||
|
||||
```go
|
||||
// src/sync/pool.go:44-63
|
||||
// Pool's purpose is to cache allocated but unused items for later reuse,
|
||||
// relieving pressure on the garbage collector. That is, it makes it easy to
|
||||
// build efficient, thread-safe free lists.
|
||||
//
|
||||
// An appropriate use of a Pool is to manage a group of temporary items
|
||||
// silently shared among and potentially reused by concurrent independent
|
||||
// clients of a package. Pool provides a way to amortize allocation overhead
|
||||
// across many clients.
|
||||
//
|
||||
// An example of good use of a Pool is in the fmt package, which maintains a
|
||||
// dynamically-sized store of temporary output buffers.
|
||||
type Pool struct {
|
||||
noCopy noCopy
|
||||
local unsafe.Pointer
|
||||
localSize uintptr
|
||||
victim unsafe.Pointer
|
||||
victimSize uintptr
|
||||
New func() any
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
Pool is **not** a general cache. Items can vanish between GC cycles. Use for reducing allocation pressure on hot paths.
|
||||
|
||||
### Idiomatic Usage (from fmt package)
|
||||
|
||||
```go
|
||||
var ppFree = sync.Pool{
|
||||
New: func() any { return new(pp) },
|
||||
}
|
||||
|
||||
func newPrinter() *pp {
|
||||
p := ppFree.Get().(*pp)
|
||||
p.reset()
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *pp) free() {
|
||||
p.buf = p.buf[:0] // reset before returning
|
||||
ppFree.Put(p)
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Use Pool for connection pooling (items disappear!)
|
||||
var connPool = sync.Pool{
|
||||
New: func() any { return connectToDB() },
|
||||
}
|
||||
// Connections may be GC'd — use database/sql's pool instead
|
||||
|
||||
// DON'T: Put dirty objects back without resetting
|
||||
pool.Put(buf) // still has data from last use
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Channel as Done Signal (Context Pattern)
|
||||
|
||||
### Source: `src/context/context.go:83-100` (Done channel), `src/io/pipe.go:42-45`
|
||||
|
||||
```go
|
||||
// src/context/context.go:83-100
|
||||
// Done returns a channel that's closed when work done on behalf of this
|
||||
// context should be canceled.
|
||||
Done() <-chan struct{}
|
||||
|
||||
// src/io/pipe.go:42-45
|
||||
type pipe struct {
|
||||
once sync.Once
|
||||
done chan struct{} // closed on pipe close
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
A `chan struct{}` costs zero bytes per send and closing it broadcasts to all receivers simultaneously. This is the canonical "done" signal in Go.
|
||||
|
||||
```go
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case result := <-work:
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Use chan bool for done signals
|
||||
done := make(chan bool) // wastes 1 byte, true/false meaningless
|
||||
|
||||
// DON'T: Send to done (only unblocks one receiver)
|
||||
done <- struct{}{}
|
||||
|
||||
// DO: Close the channel (broadcasts to all)
|
||||
close(done)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Context Propagation Rules
|
||||
|
||||
### Source: `src/context/context.go:37-48`
|
||||
|
||||
```go
|
||||
// src/context/context.go:37-48
|
||||
// Do not store Contexts inside a struct type; instead, pass a Context
|
||||
// explicitly to each function that needs it. The Context should be the first
|
||||
// parameter, typically named ctx:
|
||||
//
|
||||
// func DoSomething(ctx context.Context, arg Arg) error {
|
||||
// // ... use ctx ...
|
||||
// }
|
||||
//
|
||||
// Do not pass a nil Context, even if a function permits it. Pass context.TODO
|
||||
// if you are unsure about which Context to use.
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
Context flows **down** the call chain, never stored in structs. It carries deadlines and cancellation signals for the current request, not persistent state.
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Store context in a struct
|
||||
type Server struct {
|
||||
ctx context.Context // stale context persists beyond request
|
||||
}
|
||||
|
||||
// DON'T: Pass nil
|
||||
doWork(nil, data) // use context.TODO() if unsure
|
||||
|
||||
// DON'T: Put context anywhere other than first parameter
|
||||
func doWork(data Data, ctx context.Context) // wrong position
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Context Cancellation with Timeout
|
||||
|
||||
### Source: `src/net/http/server.go:4007-4050` (TimeoutHandler)
|
||||
|
||||
```go
|
||||
// src/net/http/server.go:4011-4050
|
||||
func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {
|
||||
ctx, cancelCtx := context.WithTimeout(r.Context(), h.dt)
|
||||
defer cancelCtx()
|
||||
r = r.WithContext(ctx)
|
||||
done := make(chan struct{})
|
||||
panicChan := make(chan any, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
panicChan <- p
|
||||
}
|
||||
}()
|
||||
h.handler.ServeHTTP(tw, r)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case p := <-panicChan:
|
||||
panic(p)
|
||||
case <-done:
|
||||
// handler completed — copy response
|
||||
case <-ctx.Done():
|
||||
// timeout — write 503
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
This is the full pattern: context with timeout + goroutine + select on done/timeout/panic. Key details:
|
||||
1. `defer cancelCtx()` — always release resources
|
||||
2. Panic propagation via dedicated channel
|
||||
3. Select on three outcomes: success, timeout, panic
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Forget to call cancel (leaks timer goroutines)
|
||||
ctx, _ := context.WithTimeout(parent, 5*time.Second)
|
||||
|
||||
// DON'T: Ignore context in long operations
|
||||
func longWork(ctx context.Context) {
|
||||
time.Sleep(10 * time.Minute) // ignores cancellation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Select with Non-Blocking Check
|
||||
|
||||
### Source: `src/io/pipe.go:51-60`
|
||||
|
||||
```go
|
||||
// src/io/pipe.go:51-60
|
||||
func (p *pipe) read(b []byte) (n int, err error) {
|
||||
select {
|
||||
case <-p.done:
|
||||
return 0, p.readCloseError()
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case bw := <-p.wrCh:
|
||||
nr := copy(b, bw)
|
||||
p.rdCh <- nr
|
||||
return nr, nil
|
||||
case <-p.done:
|
||||
return 0, p.readCloseError()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
The double-select pattern: first a non-blocking check (with `default`), then a blocking wait. The non-blocking check prevents a race where `done` was closed between the last operation and entering the blocking select.
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Check ctx.Done() in a busy loop
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
// busy-spins CPU at 100%!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Channel Pipeline (io.Pipe)
|
||||
|
||||
### Source: `src/io/pipe.go:38-45`, `src/io/pipe.go:195-205`
|
||||
|
||||
```go
|
||||
// src/io/pipe.go:38-45
|
||||
type pipe struct {
|
||||
wrCh chan []byte // writer sends data slices
|
||||
rdCh chan int // reader returns bytes consumed
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// src/io/pipe.go:195-205
|
||||
func Pipe() (*PipeReader, *PipeWriter) {
|
||||
pw := &PipeWriter{r: PipeReader{pipe: pipe{
|
||||
wrCh: make(chan []byte), // unbuffered
|
||||
rdCh: make(chan int), // unbuffered
|
||||
done: make(chan struct{}),
|
||||
}}}
|
||||
return &pw.r, pw
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
`io.Pipe` uses **unbuffered channels** — each Write blocks until Read consumes. Backpressure is automatic. The `done` channel signals shutdown.
|
||||
|
||||
### Pipeline Pattern Template
|
||||
|
||||
```go
|
||||
func generate(ctx context.Context) <-chan int {
|
||||
out := make(chan int)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case out <- i:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Forget to close channels (receivers block forever)
|
||||
func produce() <-chan int {
|
||||
ch := make(chan int)
|
||||
go func() {
|
||||
for i := 0; i < 10; i++ {
|
||||
ch <- i
|
||||
}
|
||||
// forgot close(ch) — range receivers hang
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Background Worker with Context Shutdown
|
||||
|
||||
### Source: `src/database/sql/sql.go:836-843`
|
||||
|
||||
```go
|
||||
// src/database/sql/sql.go:836-843
|
||||
func OpenDB(c driver.Connector) *DB {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
db := &DB{
|
||||
connector: c,
|
||||
openerCh: make(chan struct{}, connectionRequestQueueSize),
|
||||
stop: cancel,
|
||||
}
|
||||
go db.connectionOpener(ctx)
|
||||
return db
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
A dedicated background goroutine processes work from a buffered channel. It's controlled by a context — calling `cancel()` (stored as `db.stop`) shuts it down. This is the standard "long-lived worker goroutine with graceful shutdown" pattern.
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Start goroutines without shutdown mechanism
|
||||
go func() {
|
||||
for {
|
||||
processWork() // runs forever, no way to stop
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. noCopy — Preventing Value Copies
|
||||
|
||||
### Source: `src/sync/cond.go:120-126`
|
||||
|
||||
```go
|
||||
// src/sync/cond.go:120-126
|
||||
type noCopy struct{}
|
||||
|
||||
// Lock is a no-op used by -copylocks checker from `go vet`.
|
||||
func (*noCopy) Lock() {}
|
||||
func (*noCopy) Unlock() {}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
Embedding `noCopy` makes `go vet` report errors when the struct is copied by value. All sync primitives use this because copying a locked mutex or active WaitGroup is always a bug.
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Pass sync types by value
|
||||
func doWork(wg sync.WaitGroup) { // copies!
|
||||
defer wg.Done() // operates on copy, not original
|
||||
}
|
||||
|
||||
// DO: Pass by pointer
|
||||
func doWork(wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: Concurrency Decision Guide
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| Protect shared state | `sync.Mutex` + `defer Unlock()` |
|
||||
| One-time initialization | `sync.Once` |
|
||||
| Wait for N goroutines | `sync.WaitGroup` (prefer `.Go()` in 1.25+) |
|
||||
| Reduce allocation pressure | `sync.Pool` (not for connections!) |
|
||||
| Signal completion/cancellation | `chan struct{}` + `close()` |
|
||||
| Deadline/timeout propagation | `context.WithTimeout` / `context.WithCancel` |
|
||||
| Backpressure between producer/consumer | Unbuffered channels |
|
||||
| Long-lived background worker | Goroutine + context cancellation |
|
||||
| Prevent struct copying | Embed `noCopy` field |
|
||||
@@ -0,0 +1,519 @@
|
||||
# Go Error Handling Patterns
|
||||
|
||||
Patterns extracted from the Go standard library source code.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sentinel Errors
|
||||
|
||||
### Source: `src/io/io.go:40-43` (EOF), `src/errors/errors.go:81-83` (ErrUnsupported)
|
||||
|
||||
```go
|
||||
// src/io/io.go:40-43
|
||||
// EOF is the error returned by Read when no more input is available.
|
||||
// (Read must return EOF itself, not an error wrapping EOF,
|
||||
// because callers will test for EOF using ==.)
|
||||
var EOF = errors.New("EOF")
|
||||
|
||||
// src/io/io.go:47-49
|
||||
var ErrUnexpectedEOF = errors.New("unexpected EOF")
|
||||
```
|
||||
|
||||
```go
|
||||
// src/errors/errors.go:81-83
|
||||
var ErrUnsupported = New("unsupported operation")
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
Sentinel errors are package-level values that represent specific, well-known error conditions. They enable callers to test for specific failures:
|
||||
|
||||
```go
|
||||
if err == io.EOF {
|
||||
// end of input — not an error, just done
|
||||
}
|
||||
```
|
||||
|
||||
**Critical rule from io.EOF's doc comment**: Read must return EOF itself, **not an error wrapping EOF**, because callers test for it with `==`. This is the distinction between sentinel errors (identity-checked) and wrapped errors (tree-checked).
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Use string matching
|
||||
if err.Error() == "EOF" { ... } // fragile, not guaranteed
|
||||
|
||||
// DON'T: Return a new error each time for sentinel conditions
|
||||
func Read() error {
|
||||
return errors.New("EOF") // every call creates a new value, can't compare with ==
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. errors.New — Minimal Error Construction
|
||||
|
||||
### Source: `src/errors/errors.go:62-69`
|
||||
|
||||
```go
|
||||
// src/errors/errors.go:62-64
|
||||
func New(text string) error {
|
||||
return &errorString{text}
|
||||
}
|
||||
|
||||
// src/errors/errors.go:66-69
|
||||
type errorString struct {
|
||||
s string
|
||||
}
|
||||
|
||||
func (e *errorString) Error() string {
|
||||
return e.s
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
`errors.New` returns a pointer to a private struct. Each call creates a **distinct value** even with identical text — this is intentional for sentinel errors. Two calls to `errors.New("foo")` produce different errors (`!=`).
|
||||
|
||||
The `error` interface itself is the smallest possible:
|
||||
```go
|
||||
type error interface {
|
||||
Error() string
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Export the error type
|
||||
type MyError string // callers can create values that accidentally == your sentinels
|
||||
|
||||
// DON'T: Use plain strings as errors
|
||||
func doThing() error {
|
||||
return "something failed" // doesn't implement error interface
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Error Wrapping with fmt.Errorf and %w
|
||||
|
||||
### Source: `src/fmt/errors.go:13-23`, `src/fmt/errors.go:70-80`
|
||||
|
||||
```go
|
||||
// src/fmt/errors.go:13-23
|
||||
// Errorf formats according to a format specifier and returns the string
|
||||
// as a value that satisfies error.
|
||||
//
|
||||
// If the format specifier includes a %w verb with an error operand,
|
||||
// the returned error will implement an Unwrap method returning the operand.
|
||||
// If there is more than one %w verb, the returned error will implement an
|
||||
// Unwrap method returning a []error containing all the %w operands.
|
||||
func Errorf(format string, a ...any) (err error) { ... }
|
||||
|
||||
// src/fmt/errors.go:70-80
|
||||
type wrapError struct {
|
||||
msg string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *wrapError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
|
||||
func (e *wrapError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
`%w` creates an error chain: the returned error wraps the original. `errors.Is` and `errors.As` walk this chain. Use `%w` when callers should be able to inspect the underlying cause.
|
||||
|
||||
```go
|
||||
// Wraps: callers can detect the original error
|
||||
return fmt.Errorf("open config: %w", err)
|
||||
|
||||
// Does NOT wrap: hides the original error
|
||||
return fmt.Errorf("open config: %v", err)
|
||||
```
|
||||
|
||||
### When to use %w vs %v
|
||||
|
||||
- **%w**: When the wrapped error is part of your API contract. Callers can depend on it.
|
||||
- **%v**: When you want to include the error text but NOT let callers depend on the underlying type. Use for implementation details.
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Lose the original error
|
||||
return errors.New("failed to open config") // original error vanished
|
||||
|
||||
// DON'T: Wrap errors that aren't part of your contract with %w
|
||||
return fmt.Errorf("internal: %w", internalErr) // now callers depend on internalErr's type
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. errors.Is — Checking Error Identity Through Chains
|
||||
|
||||
### Source: `src/errors/wrap.go:30-44`
|
||||
|
||||
```go
|
||||
// src/errors/wrap.go:30-44
|
||||
func Is(err, target error) bool {
|
||||
if err == nil || target == nil {
|
||||
return err == target
|
||||
}
|
||||
isComparable := reflectlite.TypeOf(target).Comparable()
|
||||
return is(err, target, isComparable)
|
||||
}
|
||||
|
||||
func is(err, target error, targetComparable bool) bool {
|
||||
for {
|
||||
if targetComparable && err == target {
|
||||
return true
|
||||
}
|
||||
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
|
||||
return true
|
||||
}
|
||||
switch x := err.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
err = x.Unwrap()
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, err := range x.Unwrap() {
|
||||
if is(err, target, targetComparable) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
`errors.Is` walks the entire error tree (depth-first). It checks:
|
||||
1. Direct equality (`err == target`)
|
||||
2. Custom `Is(error) bool` method on the error
|
||||
3. Then unwraps and recurses
|
||||
|
||||
This means wrapped errors are transparent:
|
||||
```go
|
||||
err := fmt.Errorf("config: %w", os.ErrNotExist)
|
||||
errors.Is(err, os.ErrNotExist) // true! walks the chain
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Use == directly on potentially-wrapped errors
|
||||
if err == os.ErrNotExist { ... } // fails if err wraps ErrNotExist
|
||||
|
||||
// DO: Use errors.Is
|
||||
if errors.Is(err, os.ErrNotExist) { ... } // works through wrapping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. errors.As — Extracting Error Types Through Chains
|
||||
|
||||
### Source: `src/errors/wrap.go:96-120`
|
||||
|
||||
```go
|
||||
// src/errors/wrap.go:96-120
|
||||
func As(err error, target any) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// ... validation ...
|
||||
targetType := typ.Elem()
|
||||
return as(err, target, val, targetType)
|
||||
}
|
||||
```
|
||||
|
||||
The `as` function (line 121+) walks the tree checking `AssignableTo` and custom `As(any) bool` methods.
|
||||
|
||||
### Why
|
||||
|
||||
Extract specific error types from wrapped chains:
|
||||
|
||||
```go
|
||||
var pathErr *fs.PathError
|
||||
if errors.As(err, &pathErr) {
|
||||
fmt.Println("failed path:", pathErr.Path)
|
||||
}
|
||||
```
|
||||
|
||||
### Go 1.24+: errors.AsType (generic version)
|
||||
|
||||
From `src/errors/errors.go:48-56` doc:
|
||||
```go
|
||||
if perr, ok := errors.AsType[*fs.PathError](err); ok {
|
||||
fmt.Println(perr.Path)
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Type-assert directly on potentially-wrapped errors
|
||||
if pathErr, ok := err.(*fs.PathError); ok { ... } // fails if wrapped
|
||||
|
||||
// DO: Use errors.As
|
||||
var pathErr *fs.PathError
|
||||
if errors.As(err, &pathErr) { ... } // works through wrapping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. errors.Join — Multi-Error Aggregation
|
||||
|
||||
### Source: `src/errors/join.go:20-39`
|
||||
|
||||
```go
|
||||
// src/errors/join.go:20-39
|
||||
func Join(errs ...error) error {
|
||||
n := 0
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
e := &joinError{
|
||||
errs: make([]error, 0, n),
|
||||
}
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
e.errs = append(e.errs, err)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
```
|
||||
|
||||
The `joinError` type implements `Unwrap() []error`, making both `Is` and `As` traverse correctly.
|
||||
|
||||
### Why
|
||||
|
||||
For operations that can produce multiple errors (closing multiple resources, validating multiple fields), `Join` collects them into a single error.
|
||||
|
||||
```go
|
||||
var errs []error
|
||||
errs = append(errs, closeDB())
|
||||
errs = append(errs, closeCache())
|
||||
return errors.Join(errs...) // nil if all nil
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Return only the last error
|
||||
var lastErr error
|
||||
for _, r := range resources {
|
||||
if err := r.Close(); err != nil {
|
||||
lastErr = err // silently loses previous errors
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Custom Is() Method — Equivalence Classes
|
||||
|
||||
### Source: `src/errors/wrap.go:42-44` (doc comment), `src/context/context.go:177-179`
|
||||
|
||||
From the `errors.Is` doc:
|
||||
```go
|
||||
// An error type might provide an Is method so it can be treated as
|
||||
// equivalent to an existing error. For example, if MyError defines
|
||||
//
|
||||
// func (m MyError) Is(target error) bool { return target == fs.ErrExist }
|
||||
//
|
||||
// then Is(MyError{}, fs.ErrExist) returns true.
|
||||
```
|
||||
|
||||
Real example from context:
|
||||
```go
|
||||
// src/context/context.go:177-179
|
||||
type deadlineExceededError struct{}
|
||||
|
||||
func (deadlineExceededError) Error() string { return "context deadline exceeded" }
|
||||
func (deadlineExceededError) Timeout() bool { return true }
|
||||
func (deadlineExceededError) Temporary() bool { return true }
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
Custom `Is` methods let you define error equivalence beyond pointer identity. A `syscall.Errno` can match `fs.ErrExist` through its `Is` method, bridging OS-specific error codes to portable sentinel errors.
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Make Is() too broad
|
||||
func (e MyError) Is(target error) bool {
|
||||
return true // matches everything — defeats the purpose
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Error Wrapping in Custom Types (Unwrap pattern)
|
||||
|
||||
### Source: `src/encoding/json/encode.go:276-293`
|
||||
|
||||
```go
|
||||
// src/encoding/json/encode.go:276-282
|
||||
type MarshalerError struct {
|
||||
Type reflect.Type
|
||||
Err error
|
||||
sourceFunc string
|
||||
}
|
||||
|
||||
// src/encoding/json/encode.go:293
|
||||
func (e *MarshalerError) Unwrap() error { return e.Err }
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
Custom error types carry structured data (which type failed, which function) while still participating in the error chain via `Unwrap()`. Callers can use `errors.As` to extract the `MarshalerError` AND use `errors.Is` to check the underlying cause.
|
||||
|
||||
### Pattern Template
|
||||
|
||||
```go
|
||||
type OpError struct {
|
||||
Op string
|
||||
Path string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *OpError) Error() string {
|
||||
return e.Op + " " + e.Path + ": " + e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *OpError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Store error as string
|
||||
type MyError struct {
|
||||
Message string // lost the original error!
|
||||
}
|
||||
|
||||
// DON'T: Forget to implement Unwrap
|
||||
type MyError struct {
|
||||
Err error // has the error but errors.Is can't traverse it
|
||||
}
|
||||
func (e *MyError) Error() string { return e.Err.Error() }
|
||||
// Missing: func (e *MyError) Unwrap() error { return e.Err }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. ErrUnsupported — Feature Detection via Errors
|
||||
|
||||
### Source: `src/errors/errors.go:76-83`
|
||||
|
||||
```go
|
||||
// src/errors/errors.go:76-83
|
||||
// ErrUnsupported indicates that a requested operation cannot be performed,
|
||||
// because it is unsupported.
|
||||
//
|
||||
// Functions and methods should not return this error but should instead
|
||||
// return an error including appropriate context that satisfies
|
||||
//
|
||||
// errors.Is(err, errors.ErrUnsupported)
|
||||
//
|
||||
// either by directly wrapping ErrUnsupported or by implementing an Is method.
|
||||
var ErrUnsupported = New("unsupported operation")
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
This pattern separates "what happened" (detailed context) from "what kind of failure" (sentinel identity). Return a rich error that *wraps* or *matches* the sentinel:
|
||||
|
||||
```go
|
||||
return fmt.Errorf("chmod %s: %w", path, errors.ErrUnsupported)
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Return the sentinel directly without context
|
||||
return errors.ErrUnsupported // no info about what operation or why
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Error String Conventions
|
||||
|
||||
### Source: `src/net/http/server.go:39-56`
|
||||
|
||||
```go
|
||||
// src/net/http/server.go:39-56
|
||||
var (
|
||||
ErrHijacked = errors.New("http: connection has been hijacked")
|
||||
ErrContentLength = errors.New("http: wrote more than the declared Content-Length")
|
||||
)
|
||||
```
|
||||
|
||||
### Convention: Error String Format
|
||||
|
||||
```
|
||||
package: description
|
||||
```
|
||||
|
||||
- Lowercase (no capital first letter)
|
||||
- No trailing punctuation
|
||||
- Package prefix for disambiguation
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Capitalize error strings
|
||||
errors.New("Connection has been hijacked")
|
||||
|
||||
// DON'T: End with punctuation
|
||||
errors.New("connection failed.")
|
||||
|
||||
// DON'T: Include redundant "error" word
|
||||
errors.New("http error: connection failed") // it's already an error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: Error Handling Decision Tree
|
||||
|
||||
```
|
||||
Is this a specific, well-known condition?
|
||||
├── YES → Sentinel error (package-level var)
|
||||
│ └── Should callers detect it? → errors.Is
|
||||
└── NO → Is there structured info to convey?
|
||||
├── YES → Custom error type with Unwrap()
|
||||
│ └── Should callers extract it? → errors.As
|
||||
└── NO → fmt.Errorf with %w (wraps) or %v (doesn't wrap)
|
||||
```
|
||||
|
||||
| When to... | Use |
|
||||
|---|---|
|
||||
| Create a well-known error condition | `var ErrFoo = errors.New("pkg: foo")` |
|
||||
| Add context while preserving cause | `fmt.Errorf("doing X: %w", err)` |
|
||||
| Add context, hide internal cause | `fmt.Errorf("doing X: %v", err)` |
|
||||
| Check for a specific condition | `errors.Is(err, ErrFoo)` |
|
||||
| Extract structured error data | `errors.As(err, &target)` |
|
||||
| Aggregate multiple errors | `errors.Join(err1, err2)` |
|
||||
| Make custom types traversable | Implement `Unwrap() error` |
|
||||
| Define error equivalence | Implement `Is(error) bool` |
|
||||
@@ -0,0 +1,467 @@
|
||||
# Go Package Design Patterns
|
||||
|
||||
Patterns extracted from the Go standard library source code.
|
||||
|
||||
---
|
||||
|
||||
## 1. Package-Level Documentation
|
||||
|
||||
### Source: `src/io/io.go:5-13`, `src/sync/mutex.go:5-11`, `src/context/context.go:5-57`
|
||||
|
||||
```go
|
||||
// src/io/io.go:5-13
|
||||
// Package io provides basic interfaces to I/O primitives.
|
||||
// Its primary job is to wrap existing implementations of such primitives,
|
||||
// such as those in package os, into shared public interfaces that
|
||||
// abstract the functionality, plus some other related primitives.
|
||||
//
|
||||
// Because these interfaces and primitives wrap lower-level operations with
|
||||
// various implementations, unless otherwise informed clients should not
|
||||
// assume they are safe for parallel execution.
|
||||
package io
|
||||
```
|
||||
|
||||
```go
|
||||
// src/sync/mutex.go:5-11
|
||||
// Package sync provides basic synchronization primitives such as mutual
|
||||
// exclusion locks. Other than the Once and WaitGroup types, most are intended
|
||||
// for use by low-level library routines. Higher-level synchronization is
|
||||
// better done via channels and communication.
|
||||
//
|
||||
// Values containing the types defined in this package should not be copied.
|
||||
package sync
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
The package comment:
|
||||
1. **States the purpose** in one sentence
|
||||
2. **Establishes contracts** (not safe for parallel execution, values must not be copied)
|
||||
3. **Guides users** toward correct usage (prefer channels over mutexes)
|
||||
4. **Appears before `package` keyword** — becomes `go doc` output
|
||||
|
||||
### Convention
|
||||
|
||||
- First sentence: `"Package X does Y."` or `"Package X provides Y."`
|
||||
- For multi-file packages, put the package comment in `doc.go` or the primary file
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: No package comment
|
||||
package myutil
|
||||
|
||||
// DON'T: Restate the obvious
|
||||
// Package http provides HTTP stuff.
|
||||
package http
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Package Naming
|
||||
|
||||
### Source: All stdlib packages follow these conventions
|
||||
|
||||
**Stdlib examples:**
|
||||
- `io` — not `ioutil`, not `ioutils`
|
||||
- `fmt` — not `format`, not `formatting`
|
||||
- `sync` — not `synchronization`
|
||||
- `net/http` — not `net/httpserver`
|
||||
- `encoding/json` — not `encoding/jsonparser`
|
||||
- `context` — not `ctx` or `contexts`
|
||||
|
||||
### Why
|
||||
|
||||
Go package names are **short, lowercase, no underscores or mixedCaps**. The package name is part of every qualified identifier:
|
||||
|
||||
```go
|
||||
// Good: package name provides context
|
||||
http.Server // not http.HTTPServer
|
||||
json.Encoder // not json.JSONEncoder
|
||||
context.Context // the type IS the context
|
||||
```
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Stutter
|
||||
package http
|
||||
type HTTPServer struct{} // http.HTTPServer — redundant
|
||||
|
||||
// DON'T: Utility package names
|
||||
package utils // what does it DO?
|
||||
package helpers // grab bag, no cohesion
|
||||
package common // everything ends up here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. internal/ Packages — Restricting Visibility
|
||||
|
||||
### Source: `src/net/http/internal/`, `src/encoding/json/internal.go`
|
||||
|
||||
```
|
||||
src/net/http/internal/
|
||||
├── ascii/
|
||||
├── chunked.go
|
||||
├── http2/
|
||||
├── httpcommon/
|
||||
├── sniff.go
|
||||
└── testcert/
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
Packages under `internal/` can only be imported by code rooted at the parent of `internal`. This lets you share code between sub-packages without making it public API.
|
||||
|
||||
- `net/http/internal/ascii` → importable by `net/http` and children
|
||||
- NOT importable by `net/url` or any other package
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Export implementation details
|
||||
package mylib
|
||||
func HelperThatOnlyIUse() {} // pollutes API surface
|
||||
|
||||
// DO: Move to internal/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Export Rules — The Capital Letter Boundary
|
||||
|
||||
### Source: `src/io/io.go` — exported vs unexported
|
||||
|
||||
```go
|
||||
// src/io/io.go
|
||||
var EOF = errors.New("EOF") // exported: uppercase
|
||||
var errInvalidWrite = errors.New(...) // unexported: lowercase
|
||||
|
||||
type teeReader struct { // unexported type
|
||||
r Reader
|
||||
w Writer
|
||||
}
|
||||
|
||||
func TeeReader(r Reader, w Writer) Reader { // exported constructor
|
||||
return &teeReader{r, w}
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
`teeReader` is unexported because:
|
||||
1. Users don't need to know its implementation
|
||||
2. The return type is `Reader` (interface) — maximum flexibility
|
||||
3. The struct's fields can change without breaking anyone
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Export everything "just in case"
|
||||
type Parser struct {
|
||||
Input string // should this be settable?
|
||||
buffer []byte // internal state
|
||||
pos int
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. init() Functions — Use Sparingly
|
||||
|
||||
### Source: `src/net/http/http2.go:37`
|
||||
|
||||
```go
|
||||
// src/net/http/http2.go:37
|
||||
func init() {
|
||||
// register HTTP/2 protocol implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
The stdlib uses `init()` for:
|
||||
- **Driver registration** (database drivers register via init)
|
||||
- **Protocol negotiation** (HTTP/2 registers its handler)
|
||||
|
||||
### Rules
|
||||
|
||||
1. Should have no side effects beyond registration
|
||||
2. No errors possible (can't return error from init)
|
||||
3. Keep them short
|
||||
4. Prefer explicit initialization in `main()` when possible
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Do heavy work in init
|
||||
func init() {
|
||||
db = connectToDatabase() // fails silently, crashes later
|
||||
cache = loadGigabyteFile() // blocks startup
|
||||
}
|
||||
|
||||
// DO: Prefer explicit setup in main()
|
||||
func main() {
|
||||
db, err := connectToDatabase()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Functional Options Pattern
|
||||
|
||||
The stdlib uses struct-based configuration (`http.Server`, `tls.Config`). The functional options pattern emerged from the community for APIs with many optional parameters:
|
||||
|
||||
```go
|
||||
// The pattern (idiom from Rob Pike/Dave Cheney):
|
||||
type Option func(*Server)
|
||||
|
||||
func WithTimeout(d time.Duration) Option {
|
||||
return func(s *Server) {
|
||||
s.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
func NewServer(addr string, opts ...Option) *Server {
|
||||
s := &Server{addr: addr, timeout: 30 * time.Second}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
### What stdlib uses: Config structs
|
||||
|
||||
```go
|
||||
// net/http — struct literal configuration
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
Handler: mux,
|
||||
}
|
||||
```
|
||||
|
||||
### When to use which
|
||||
|
||||
| Approach | When |
|
||||
|----------|------|
|
||||
| Config struct | Few options, all data (stdlib preference) |
|
||||
| Functional options | Many options, some involve behavior, public API stability |
|
||||
|
||||
---
|
||||
|
||||
## 7. Constructor Pattern — NewX Functions
|
||||
|
||||
### Source: `src/net/http/server.go:2639`, `src/database/sql/sql.go:836`
|
||||
|
||||
```go
|
||||
// src/net/http/server.go:2639
|
||||
func NewServeMux() *ServeMux {
|
||||
return new(ServeMux)
|
||||
}
|
||||
|
||||
// src/database/sql/sql.go:836-843
|
||||
func OpenDB(c driver.Connector) *DB {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
db := &DB{
|
||||
connector: c,
|
||||
openerCh: make(chan struct{}, connectionRequestQueueSize),
|
||||
stop: cancel,
|
||||
}
|
||||
go db.connectionOpener(ctx)
|
||||
return db
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
- `NewX()` when construction is trivial
|
||||
- `OpenX()` when construction involves resources or can fail
|
||||
- Return `*T` (concrete), not an interface
|
||||
- Zero value should be usable where possible (`sync.Mutex`, `bytes.Buffer`)
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Constructor that returns interface
|
||||
func NewWriter() io.Writer { return &myWriter{} } // hides methods
|
||||
|
||||
// DON'T: Require constructor when zero value works
|
||||
// var b bytes.Buffer ← just works
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Package Organization — One Concern Per Package
|
||||
|
||||
### Source: Standard library structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── io/ # I/O interfaces + helpers
|
||||
├── os/ # OS operations
|
||||
├── net/ # network primitives
|
||||
│ ├── http/ # HTTP protocol
|
||||
│ └── url/ # URL parsing
|
||||
├── encoding/
|
||||
│ ├── json/ # JSON codec
|
||||
│ └── xml/ # XML codec
|
||||
├── database/
|
||||
│ └── sql/ # SQL abstraction
|
||||
│ └── driver/ # SPI for drivers
|
||||
└── context/ # cancellation propagation
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
Each package has a single, clear responsibility. Packages communicate through interfaces, not shared state.
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Package per type (50 packages with 1 file each)
|
||||
package user
|
||||
package order
|
||||
package payment
|
||||
|
||||
// DON'T: Circular dependencies
|
||||
package a imports package b
|
||||
package b imports package a // compile error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. API Layering — User vs Implementor (database/sql)
|
||||
|
||||
### Source: `src/database/sql/sql.go` vs `src/database/sql/driver/driver.go`
|
||||
|
||||
**User-facing (database/sql):**
|
||||
```go
|
||||
db, _ := sql.Open("postgres", connStr)
|
||||
rows, _ := db.QueryContext(ctx, "SELECT ...")
|
||||
```
|
||||
|
||||
**Driver-facing (database/sql/driver):**
|
||||
```go
|
||||
type Driver interface {
|
||||
Open(name string) (Conn, error)
|
||||
}
|
||||
type Conn interface {
|
||||
Prepare(query string) (Stmt, error)
|
||||
Close() error
|
||||
Begin() (Tx, error)
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
The user never sees `driver.Conn`. The driver never sees `sql.DB`'s pool logic. Clean separation: users get high-level safe API; drivers implement minimal interface.
|
||||
|
||||
---
|
||||
|
||||
## 10. Context Key Pattern — Type-Safe Context Values
|
||||
|
||||
### Source: `src/context/context.go:132-164`, `src/net/http/server.go:244-252`
|
||||
|
||||
```go
|
||||
// src/context/context.go:132-164 (from doc)
|
||||
// package user
|
||||
//
|
||||
// type key int
|
||||
// var userKey key
|
||||
//
|
||||
// func NewContext(ctx context.Context, u *User) context.Context {
|
||||
// return context.WithValue(ctx, userKey, u)
|
||||
// }
|
||||
//
|
||||
// func FromContext(ctx context.Context) (*User, bool) {
|
||||
// u, ok := ctx.Value(userKey).(*User)
|
||||
// return u, ok
|
||||
// }
|
||||
```
|
||||
|
||||
```go
|
||||
// src/net/http/server.go:244-252
|
||||
var (
|
||||
ServerContextKey = &contextKey{"http-server"}
|
||||
LocalAddrContextKey = &contextKey{"local-addr"}
|
||||
)
|
||||
|
||||
type contextKey struct {
|
||||
name string
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
- **Unexported key type** prevents other packages from accessing your values
|
||||
- **Type-safe accessors** avoid repeated type assertions
|
||||
- **Pointer-based keys** guarantee uniqueness
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```go
|
||||
// DON'T: Use string keys (collision risk)
|
||||
ctx = context.WithValue(ctx, "user", user)
|
||||
|
||||
// DON'T: Store optional parameters in context
|
||||
ctx = context.WithValue(ctx, "timeout", 5*time.Second) // use function params!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Struct Tags for Codec Configuration
|
||||
|
||||
### Source: `src/encoding/json/tags.go:17-21`, `src/encoding/json/encode.go:101-181`
|
||||
|
||||
```go
|
||||
// src/encoding/json/tags.go:17-21
|
||||
func parseTag(tag string) (string, tagOptions) {
|
||||
tag, opt, _ := strings.Cut(tag, ",")
|
||||
return tag, tagOptions(opt)
|
||||
}
|
||||
```
|
||||
|
||||
Usage in struct definitions:
|
||||
```go
|
||||
type Person struct {
|
||||
Name string `json:"name"`
|
||||
Age int `json:"age,omitempty"`
|
||||
Secret string `json:"-"` // always omitted
|
||||
Address string `json:"addr,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### Why
|
||||
|
||||
Struct tags are metadata for codecs. The `json` package reads `json:"..."` tags via reflection to control field names and behavior. The format is `key:"value"` with comma-separated options.
|
||||
|
||||
### Convention (from encode.go docs, line 101-181)
|
||||
|
||||
- `json:"fieldname"` — override JSON key name
|
||||
- `json:",omitempty"` — omit if zero value
|
||||
- `json:"-"` — never include
|
||||
- `json:"-,"` — use literal `-` as name
|
||||
|
||||
---
|
||||
|
||||
## Summary: Package Design Principles
|
||||
|
||||
| Principle | Rule |
|
||||
|-----------|------|
|
||||
| Package comment | `"Package X does Y."` before `package` keyword |
|
||||
| Naming | Short, lowercase, no stutter |
|
||||
| Encapsulation | `internal/` for private shared code |
|
||||
| Exports | Minimum surface; unexported by default |
|
||||
| init() | Only for registration; prefer explicit setup |
|
||||
| Constructors | `NewX()` → `*T`; prefer usable zero values |
|
||||
| Organization | One concern per package |
|
||||
| API layers | Separate user from implementor (SPI) |
|
||||
| Context values | Unexported key type + typed accessors |
|
||||
| Configuration | Struct literals or functional options |
|
||||
@@ -0,0 +1,586 @@
|
||||
# Advanced Go Testing Patterns
|
||||
|
||||
Patterns extracted from the Go standard library (`src/net/http/`, `src/encoding/json/`, `src/testing/`) and Kubernetes source code.
|
||||
|
||||
---
|
||||
|
||||
## 1. Table-Driven Tests
|
||||
|
||||
The canonical Go test style. Every Go stdlib test file uses this pattern.
|
||||
|
||||
### Pattern Name: Anonymous Struct Test Table
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/header_test.go` lines 17-108
|
||||
|
||||
**What they do:** Define test cases as a slice of anonymous structs, iterate with a range loop.
|
||||
|
||||
**Why:** Eliminates repetition, makes adding cases trivial, keeps the assertion logic in one place. Every test case gets the same verification path — no "special" cases hidden in different code paths.
|
||||
|
||||
**Anti-pattern:** Writing individual assertions for each case, or copy-pasting test functions that differ by one input.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
var headerWriteTests = []struct {
|
||||
h Header
|
||||
exclude map[string]bool
|
||||
expected string
|
||||
}{
|
||||
{Header{}, nil, ""},
|
||||
{
|
||||
Header{
|
||||
"Content-Type": {"text/html; charset=UTF-8"},
|
||||
"Content-Length": {"0"},
|
||||
},
|
||||
nil,
|
||||
"Content-Length: 0\r\nContent-Type: text/html; charset=UTF-8\r\n",
|
||||
},
|
||||
// ... more cases
|
||||
}
|
||||
|
||||
func TestHeaderWrite(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
for i, test := range headerWriteTests {
|
||||
test.h.WriteSubset(&buf, test.exclude)
|
||||
if buf.String() != test.expected {
|
||||
t.Errorf("#%d:\n got: %q\nwant: %q", i, buf.String(), test.expected)
|
||||
}
|
||||
buf.Reset()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern Name: Named Table Tests with t.Run (Subtests)
|
||||
|
||||
**Source:** `/tmp/go-src/src/encoding/json/encode_test.go` lines 285-320, `/tmp/go-src/src/encoding/json/scanner_test.go` lines 30-50
|
||||
|
||||
**What they do:** Combine table-driven tests with `t.Run` for named subtests. Use a `CaseName` struct that captures file/line for error reporting.
|
||||
|
||||
**Why:** Each case gets its own subtest name — visible in `go test -v`, filterable with `-run`, and individually re-runnable. The `CaseName`/`Where` pattern provides precise file:line for failures even in large test tables.
|
||||
|
||||
**Anti-pattern:** Using index-only identification (hard to find which case failed), or creating separate `TestFoo_Case1`, `TestFoo_Case2` functions.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
func TestValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
CaseName
|
||||
data string
|
||||
ok bool
|
||||
}{
|
||||
{Name(""), `foo`, false},
|
||||
{Name(""), `}{`, false},
|
||||
{Name(""), `{}`, true},
|
||||
{Name("StringDoubleEscapes"), `{"foo":"bar"}`, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if ok := Valid([]byte(tt.data)); ok != tt.ok {
|
||||
t.Errorf("%s: Valid(`%s`) = %v, want %v", tt.Where, tt.data, ok, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern Name: CaseName with Caller Position Tracking
|
||||
|
||||
**Source:** `/tmp/go-src/src/encoding/json/internal/jsontest/testcase.go` lines 18-37
|
||||
|
||||
**What they do:** Create a helper type that captures the caller's file:line at the point of test case declaration, so error messages point back to the exact test case definition.
|
||||
|
||||
**Why:** In a 1000-entry test table, `t.Errorf` points to the assertion line (same for all cases). CaseName makes failures point to the case definition.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
type CaseName struct {
|
||||
Name string
|
||||
Where CasePos
|
||||
}
|
||||
|
||||
func Name(s string) (c CaseName) {
|
||||
c.Name = s
|
||||
runtime.Callers(2, c.Where.pc[:])
|
||||
return c
|
||||
}
|
||||
|
||||
type CasePos struct{ pc [1]uintptr }
|
||||
|
||||
func (pos CasePos) String() string {
|
||||
frames := runtime.CallersFrames(pos.pc[:])
|
||||
frame, _ := frames.Next()
|
||||
return fmt.Sprintf("%s:%d", path.Base(frame.File), frame.Line)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Test Helper Patterns
|
||||
|
||||
### Pattern Name: t.Helper() for Clean Stack Traces
|
||||
|
||||
**Source:** `/tmp/go-src/src/testing/testing.go` lines 1415-1435
|
||||
|
||||
**What they do:** Call `t.Helper()` as the first line in any test utility function. This marks the function as a helper, so test failure messages report the caller's line instead of the helper's line.
|
||||
|
||||
**Why:** Without `t.Helper()`, every failure in a helper function points to the helper itself, not the test case that triggered the failure. Makes debugging test failures require reading the full stack.
|
||||
|
||||
**Anti-pattern:** Writing test utilities that call `t.Fatal`/`t.Error` without marking themselves as helpers.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
// From net/http/clientserver_test.go lines 100-131
|
||||
func run[T TBRun[T]](t T, f func(t T, mode testMode), opts ...any) {
|
||||
t.Helper()
|
||||
modes := []testMode{http1Mode, http2Mode, http3Mode}
|
||||
parallel := true
|
||||
for _, opt := range opts {
|
||||
switch opt := opt.(type) {
|
||||
case []testMode:
|
||||
modes = opt
|
||||
case testNotParallelOpt:
|
||||
parallel = false
|
||||
default:
|
||||
t.Fatalf("unknown option type %T", opt)
|
||||
}
|
||||
}
|
||||
// ...
|
||||
for _, mode := range modes {
|
||||
t.Run(string(mode), func(t T) {
|
||||
t.Helper()
|
||||
// ...
|
||||
f(t, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern Name: *testing.T as First Argument to Helpers
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/serve_test.go` lines 4555-4580
|
||||
|
||||
**What they do:** Pass `*testing.T` (or `testing.TB`) as the first argument to test helper functions, making the dependency on the test context explicit.
|
||||
|
||||
**Why:** The test object provides `Fatal`, `Error`, `Log`, `Helper`, `Cleanup` — everything a helper needs for reporting. Accepting it as a parameter (rather than capturing it in a closure) makes helpers reusable across tests.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
mustGet := func(url string, headers ...string) {
|
||||
t.Helper()
|
||||
req, err := NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for len(headers) > 0 {
|
||||
req.Header.Add(headers[0], headers[1])
|
||||
headers = headers[2:]
|
||||
}
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Errorf("Error fetching %s: %v", url, err)
|
||||
return
|
||||
}
|
||||
_, err = io.ReadAll(res.Body)
|
||||
defer res.Body.Close()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. t.Cleanup vs defer
|
||||
|
||||
### Pattern Name: t.Cleanup for Test-Scoped Resources
|
||||
|
||||
**Source:** `/tmp/go-src/src/testing/testing.go` lines 1439-1468, `/tmp/go-src/src/net/http/clientserver_test.go` lines 120-127
|
||||
|
||||
**What they do:** Use `t.Cleanup(fn)` instead of `defer` for resource cleanup in tests.
|
||||
|
||||
**Why:**
|
||||
1. `defer` runs at the end of the *function*, not the *test*. In subtests launched with `t.Run`, a `defer` in a helper function runs when the helper returns — not when the subtest completes.
|
||||
2. `t.Cleanup` runs after the test AND all its subtests finish — guaranteeing resources are available for the full test lifetime.
|
||||
3. `t.Cleanup` is called in reverse order (LIFO), matching `defer` semantics but scoped to the test.
|
||||
|
||||
**Anti-pattern:** Using `defer` for cleanup in test setup functions that return before the test finishes, or in subtests where timing matters.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
// From net/http/clientserver_test.go
|
||||
func run[T TBRun[T]](t T, f func(t T, mode testMode), opts ...any) {
|
||||
// ...
|
||||
for _, mode := range modes {
|
||||
t.Run(string(mode), func(t T) {
|
||||
t.Cleanup(func() {
|
||||
afterTest(t) // Goroutine leak detection — runs AFTER subtest body completes
|
||||
})
|
||||
f(t, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. testdata/ Directory Pattern
|
||||
|
||||
### Pattern Name: testdata/ for Test Fixtures
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/testdata/` (contains `file`, `index.html`, `style.css`), `/tmp/go-src/src/net/http/fs_test.go` line 38
|
||||
|
||||
**What they do:** Store test fixtures in a `testdata/` directory adjacent to the test files. Reference them with relative paths like `"testdata/file"`.
|
||||
|
||||
**Why:**
|
||||
1. `go build` ignores `testdata/` directories — they never end up in production binaries.
|
||||
2. `go test` runs with the package directory as CWD — relative paths to `testdata/` work reliably.
|
||||
3. Fixtures are version-controlled alongside the code they test.
|
||||
4. Separates test data from test logic.
|
||||
|
||||
**Anti-pattern:** Embedding large test fixtures as string literals in test files, or referencing absolute paths.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
// From net/http/fs_test.go line 38
|
||||
const testFile = "testdata/file"
|
||||
|
||||
// Usage in test:
|
||||
ServeFile(w, r, "testdata/file")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Golden File Testing
|
||||
|
||||
### Pattern Name: Golden Files with -update Flag
|
||||
|
||||
**Source:** `/tmp/go-src/src/cmd/gofmt/gofmt_test.go` lines 18, 113-138
|
||||
|
||||
**What they do:** Compare test output against `.golden` files. Provide a `-update` flag that regenerates golden files from current output when behavior intentionally changes.
|
||||
|
||||
**Why:**
|
||||
1. Tests complex output (formatted code, generated HTML, serialized data) without embedding it in test code.
|
||||
2. The `-update` flag makes intentional changes easy: run `go test -update`, review the diff, commit.
|
||||
3. Golden files serve as documentation of expected behavior.
|
||||
4. Reviewers can see exactly what output changed in diffs.
|
||||
|
||||
**Anti-pattern:** Comparing against inline expected strings that span 50+ lines, or manually constructing expected output.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
var update = flag.Bool("update", false, "update .golden files")
|
||||
|
||||
func runTest(t *testing.T, in, out string) {
|
||||
// ... produce actual output ...
|
||||
|
||||
expected, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if got := buf.Bytes(); !bytes.Equal(got, expected) {
|
||||
if *update {
|
||||
if in != out {
|
||||
if err := os.WriteFile(out, got, 0666); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("(gofmt %s) != %s\n%s", in, out,
|
||||
diff.Diff("expected", expected, "got", got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewrite(t *testing.T) {
|
||||
match, _ := filepath.Glob("testdata/*.input")
|
||||
for _, in := range match {
|
||||
name := filepath.Base(in)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
out := in[:len(in)-len(".input")] + ".golden"
|
||||
runTest(t, in, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. httptest Patterns
|
||||
|
||||
### Pattern Name: httptest.NewRecorder for Unit-Testing Handlers
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/serve_test.go` lines 387-393
|
||||
|
||||
**What they do:** Use `httptest.NewRecorder()` to test HTTP handlers without starting a server. Captures status code, headers, and body.
|
||||
|
||||
**Why:** Fast, no network, no port allocation, no goroutines. Perfect for unit testing individual handlers in isolation.
|
||||
|
||||
**Anti-pattern:** Spinning up a full server to test handler logic that doesn't need networking.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
func TestServeMuxHandler(t *testing.T) {
|
||||
mux := NewServeMux()
|
||||
for _, e := range serveMuxRegister {
|
||||
mux.Handle(e.pattern, e.h)
|
||||
}
|
||||
for _, tt := range serveMuxTests {
|
||||
r := &Request{Method: tt.method, Host: tt.host, URL: &url.URL{Path: tt.path}}
|
||||
h, pattern := mux.Handler(r)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, r)
|
||||
if pattern != tt.pattern || rr.Code != tt.code {
|
||||
t.Errorf("%s %s %s = %d, %q, want %d, %q",
|
||||
tt.method, tt.host, tt.path, rr.Code, pattern, tt.code, tt.pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern Name: httptest.NewServer for Integration-Style Tests
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/clientserver_test.go` lines 203-280
|
||||
|
||||
**What they do:** Use `httptest.NewServer` / `httptest.NewUnstartedServer` for end-to-end HTTP testing with a real TCP listener on localhost.
|
||||
|
||||
**Why:** Tests the full HTTP stack including transport, TLS, connection pooling, timeouts. The `clientServerTest` helper in the stdlib runs each test across HTTP/1.1, HTTP/2, and HTTP/3 modes.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
func newClientServerTest(t testing.TB, mode testMode, h Handler, opts ...any) *clientServerTest {
|
||||
cst := &clientServerTest{t: t, h2: mode == http2Mode, h: h}
|
||||
cst.ts = httptest.NewUnstartedServer(h)
|
||||
// ... configure based on mode ...
|
||||
switch mode {
|
||||
case http1Mode:
|
||||
cst.ts.Start()
|
||||
case http2Mode:
|
||||
cst.ts.EnableHTTP2 = true
|
||||
cst.ts.StartTLS()
|
||||
}
|
||||
cst.c = cst.ts.Client()
|
||||
t.Cleanup(cst.close)
|
||||
return cst
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Benchmark Patterns
|
||||
|
||||
### Pattern Name: b.ReportAllocs + b.RunParallel + b.SetBytes
|
||||
|
||||
**Source:** `/tmp/go-src/src/encoding/json/bench_test.go` lines 85-101
|
||||
|
||||
**What they do:** Combine `b.ReportAllocs()` for allocation reporting, `b.RunParallel` for concurrent benchmarks, and `b.SetBytes` for throughput metrics.
|
||||
|
||||
**Why:**
|
||||
- `b.ReportAllocs()` shows allocations/op — critical for hot paths.
|
||||
- `b.RunParallel` measures performance under contention (real-world server behavior).
|
||||
- `b.SetBytes` converts to MB/s throughput — meaningful for serialization benchmarks.
|
||||
|
||||
**Anti-pattern:** Benchmarks that only measure wall time without allocation tracking, or sequential benchmarks for concurrent code.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
func BenchmarkCodeEncoder(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
if codeJSON == nil {
|
||||
b.StopTimer()
|
||||
codeInit()
|
||||
b.StartTimer()
|
||||
}
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
enc := NewEncoder(io.Discard)
|
||||
for pb.Next() {
|
||||
if err := enc.Encode(&codeStruct); err != nil {
|
||||
b.Fatalf("Encode error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.SetBytes(int64(len(codeJSON)))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Integration Test Separation
|
||||
|
||||
### Pattern Name: testing.Short() for Expensive Tests
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/serve_test.go` lines 800, 1000, 2212, 2581
|
||||
|
||||
**What they do:** Skip slow/flaky/network-dependent tests with `testing.Short()`. The Go CI runs with `-short` in fast mode, full tests in thorough mode.
|
||||
|
||||
**Why:** Fast feedback loop for development (`go test -short`), full validation in CI. No custom build tags needed.
|
||||
|
||||
**Anti-pattern:** Separate `_integration_test.go` files with build tags (Go stdlib doesn't do this), or always-slow tests that can't be skipped.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
func TestServerTimeouts(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping in short mode")
|
||||
}
|
||||
// ... expensive test with real timeouts ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. No Assertion Libraries in Stdlib
|
||||
|
||||
### Pattern Name: Plain if/t.Errorf Over Assertion Frameworks
|
||||
|
||||
**Source:** Every test file in `/tmp/go-src/src/` (zero imports of `testify`, `gomega`, or any assertion library)
|
||||
|
||||
**What they do:** Use plain Go: `if got != want { t.Errorf(...) }`. Never import assertion libraries.
|
||||
|
||||
**Why:**
|
||||
1. No implicit control flow — `t.Errorf` continues execution, so you see ALL failures at once.
|
||||
2. No magic — the test reads like regular Go code.
|
||||
3. Error messages are custom-crafted for each assertion, providing context that generic `assert.Equal` cannot.
|
||||
4. One less dependency.
|
||||
|
||||
**Anti-pattern (Kubernetes uses this, stdlib does NOT):**
|
||||
```go
|
||||
// Kubernetes style (not stdlib):
|
||||
assert.Equal(t, expected, actual)
|
||||
require.NoError(t, err)
|
||||
```
|
||||
|
||||
**Stdlib style:**
|
||||
```go
|
||||
if got := v.Elem().Interface(); !reflect.DeepEqual(got, tt.out) {
|
||||
t.Fatalf("%s: Decode:\n\tgot: %#v\n\twant: %#v", tt.Where, got, tt.out)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Goroutine Leak Detection
|
||||
|
||||
### Pattern Name: TestMain + afterTest Goroutine Checking
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/main_test.go` (entire file)
|
||||
|
||||
**What they do:** `TestMain` runs the test suite and checks for leaked goroutines after all tests complete. `afterTest` checks for goroutine leaks after each individual test.
|
||||
|
||||
**Why:** HTTP code spawns goroutines for connections, background reads, etc. Leaked goroutines indicate resource leaks (connections not closed, servers not shut down). Catching them prevents production OOMs.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
v := m.Run()
|
||||
if v == 0 && goroutineLeaked() {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(v)
|
||||
}
|
||||
|
||||
func goroutineLeaked() bool {
|
||||
for i := 0; i < 5; i++ {
|
||||
gs := interestingGoroutines()
|
||||
if len(gs) == 0 {
|
||||
return false
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
// Report leaked goroutines
|
||||
return true
|
||||
}
|
||||
|
||||
func afterTest(t testing.TB) {
|
||||
http.DefaultTransport.(*http.Transport).CloseIdleConnections()
|
||||
// Check for leaked goroutines from this specific test...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. export_test.go Pattern
|
||||
|
||||
### Pattern Name: Bridge File for Internal Testing
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/export_test.go` lines 1-50
|
||||
|
||||
**What they do:** Create an `export_test.go` file in the package itself (package `http`, not `http_test`) that exports internal symbols to external test packages. Only compiled during testing.
|
||||
|
||||
**Why:** Allows `http_test` (external test package) to access internals needed for white-box testing without polluting the public API. The `_test.go` suffix means it's never included in production builds.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
// export_test.go — package http (not http_test!)
|
||||
package http
|
||||
|
||||
var (
|
||||
DefaultUserAgent = defaultUserAgent
|
||||
ExportRefererForURL = refererForURL
|
||||
ExportServerNewConn = (*Server).newConn
|
||||
ExportErrRequestCanceled = errRequestCanceled
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Multi-Mode Test Runner
|
||||
|
||||
### Pattern Name: Generic Test Runner Across Protocol Modes
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/clientserver_test.go` lines 100-134
|
||||
|
||||
**What they do:** A generic `run[T]` function that executes every client/server test in HTTP/1.1, HTTP/2, and HTTP/3 modes automatically. Tests opt into specific modes via options.
|
||||
|
||||
**Why:** Ensures behavioral consistency across protocol versions. A single test function covers all modes — no duplication. Bugs in one protocol version are caught immediately.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
// Test declaration (one line runs across 3 protocols):
|
||||
func TestServerTimeouts(t *testing.T) { run(t, testServerTimeouts, []testMode{http1Mode}) }
|
||||
|
||||
// The runner:
|
||||
func run[T TBRun[T]](t T, f func(t T, mode testMode), opts ...any) {
|
||||
t.Helper()
|
||||
modes := []testMode{http1Mode, http2Mode, http3Mode}
|
||||
for _, mode := range modes {
|
||||
t.Run(string(mode), func(t T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { afterTest(t) })
|
||||
f(t, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. testLogWriter — Routing Server Logs to Test Output
|
||||
|
||||
### Pattern Name: io.Writer Adapter for *testing.T
|
||||
|
||||
**Source:** `/tmp/go-src/src/net/http/clientserver_test.go` lines 337-345
|
||||
|
||||
**What they do:** Implement `io.Writer` backed by `t.Logf`, so server error logs appear in test output (visible with `-v`, suppressed otherwise).
|
||||
|
||||
**Why:** Server logs are crucial for debugging test failures but shouldn't clutter passing output. `t.Log` gives you both: silent on pass, verbose on fail.
|
||||
|
||||
**Code example (stdlib):**
|
||||
```go
|
||||
type testLogWriter struct {
|
||||
t testing.TB
|
||||
}
|
||||
|
||||
func (w testLogWriter) Write(b []byte) (int, error) {
|
||||
w.t.Logf("server log: %v", strings.TrimSpace(string(b)))
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// Usage:
|
||||
cst.ts.Config.ErrorLog = log.New(testLogWriter{t}, "", 0)
|
||||
```
|
||||
Reference in New Issue
Block a user