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 Resolver struct {
runner GoListRunner
}
type GoListRunner interface {
List(ctx context.Context, patterns []string) ([]byte, error)
}
func (r *Resolver) Resolve(ctx context.Context, patterns []string, includeTests bool) ([]Target, error) {
stdout, err := r.runner.List(ctx, patterns)
if err != nil {
return nil, err
}
// ... parse JSON, build Targets
return targets, nil
}
Test¶
func checkResolveError(want string) checkResolverResolveFn {
return func(t *testing.T, _ []Target, err error) {
t.Helper()
if assert.Error(t, err) {
assert.Contains(t, err.Error(), want)
}
}
}
func checkTargetsCount(want int) checkResolverResolveFn {
return func(t *testing.T, got []Target, err error) {
t.Helper()
assert.NoError(t, err)
assert.Len(t, got, want)
}
}
func TestResolver_Resolve(t *testing.T) {
tests := []struct {
name string
before func(*Resolver)
checks []checkResolverResolveFn
}{
{
name: "success",
before: func(r *Resolver) {
r.runner = &mockGoListRunner{
listFn: func(ctx context.Context, patterns []string) ([]byte, error) {
return []byte(`{"ImportPath":"pkg/a","Dir":"/go/pkg/a","GoFiles":["a.go"]}`), nil
},
}
},
checks: checkResolverResolve(
checkTargetsCount(1),
checkTargetImportPath("pkg/a"),
),
},
{
name: "runner returns error",
before: func(r *Resolver) {
r.runner = &mockGoListRunner{
listFn: func(context.Context, []string) ([]byte, error) {
return nil, assert.AnError
},
}
},
checks: checkResolverResolve(
checkResolveError("assert.AnError"),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewResolver(&ResolverConfig{})
if tt.before != nil {
tt.before(r)
}
got, err := r.Resolve(ctx, []string{"./..."}, false)
for _, fn := range tt.checks {
fn(t, got, err)
}
})
}
}
Scaffold¶
Generate test scaffolding with go-testgen:
Testing Approach¶
The before hook pattern:
- Fresh state per case —
before()returns a newmockGoListRunnerwith the configured JSON response. Each subtest gets its own mock instance. No risk of test A's mock state leaking into test B. - Typed return struct —
beforeReturnsdocuments exactly what the test fixture provides. Adding a new dependency (e.g. a clock, a config flag) 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