Chapter 10: Before Hook Pattern¶
Description¶
Extract test setup to a before hook function that returns a fresh fixture state. Each test case calls before() explicitly instead of a shared setup() mutated by every test. This prevents test pollution from shared mutable state (rate limiters, timestamps, sequence generators).
Code¶
type RateLimiter struct {
mu sync.Mutex
requests int
maxPerMin int
}
func (r *RateLimiter) Allow() bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.requests >= r.maxPerMin {
return false
}
r.requests++
return true
}
Test¶
func TestRateLimiter(t *testing.T) {
type beforeReturns struct {
limiter *RateLimiter
}
before := func(t *testing.T, max int) beforeReturns {
t.Helper()
return beforeReturns{
limiter: NewRateLimiter(max),
}
}
t.Run("allows within limit", func(t *testing.T) {
f := before(t, 3)
assert.True(t, f.limiter.Allow())
assert.True(t, f.limiter.Allow())
assert.True(t, f.limiter.Allow())
})
t.Run("blocks after limit", func(t *testing.T) {
f := before(t, 2)
assert.True(t, f.limiter.Allow())
assert.True(t, f.limiter.Allow())
assert.False(t, f.limiter.Allow()) // blocked
})
t.Run("separate limiters are isolated", func(t *testing.T) {
a := before(t, 1)
b := before(t, 1)
assert.True(t, a.limiter.Allow())
assert.True(t, b.limiter.Allow())
assert.False(t, a.limiter.Allow())
assert.False(t, b.limiter.Allow())
})
}
Testing Approach¶
The before hook pattern:
- Fresh state per case —
before()returns a newRateLimiterwith the configured max. Each subtest gets its own instance. No risk of test A consuming requests and affecting test B. - Typed return struct —
beforeReturnsdocuments exactly what the test fixture provides. Adding a new dependency (e.g. a clock) doesn't require changing every test's setup, just the struct and thebeforefunction. - Explicit over shared — no
init()orTestMainsetup. Every subtest callsbefore()and sees the fixture it depends on. Makes the test self-documenting. t.Helper()— thebeforehook callst.Helper(), so failure line numbers point to the test assertion, not inside the setup function.
Beyond Setup: Before/Checks/After Phases¶
When tests involve goroutines, mocked dependencies, or explicit teardown, the before pattern extends to a three-phase lifecycle:
- Before — configure mocks and set up the system under test
- Exercise — call the method being tested
- Checks — assert on captured output
- After — trigger shutdown, cancel context, verify channel cleanup
The tests in Chapter 22 (Sensor) and Chapter 33 (Event-Driven Run Loop) demonstrate this: mock transport expectations are wired in before(), the Run() channel is observed with select + timeout, received values are checked by typed closures, and after() calls s.Stop() or cancel() to tear down the goroutine and verify channel closure.
This structured lifecycle makes it easy to add new test cases: each entry in the table specifies only what differs — mock setup, assertions, and teardown — without repeating the boilerplate of creating contexts, starting goroutines, or draining channels.
View source code on GitHub