One shared func-field fake for a wide interface
byob-testing.5
interfacesstoragetesting
Problem: byob-interfaces.1 (consumer-scoped narrow interfaces) is the right default, but a real storage interface shared by a dozen commands grows organically to 20+ methods — and at that width the narrow-interface playbook stops paying. Twenty per-consumer interface declarations over one concrete store is twenty declarations that drift; hand-rolling a fresh fake per test means stubbing 20 methods to exercise one; mockery/counterfeiter add a codegen step and an expectation DSL that pushes tests toward asserting call sequences — exactly what byob-testing.3 warns against. This decision is the honest exit ramp, learned the long way: keep the one wide interface, and make its fake cheap.
Idea: one shared fake in a sibling test package
(internal/storage/storagetest/), shaped as a struct with one
exported func field per interface method. Each method delegates
to its field if set, otherwise returns a benign zero-value success —
with one carve-out: get-one lookups default to the domain's
not-found sentinel (nil, storage.ErrNotFound), not (nil, nil).
Zero-value "success" from a lookup is a nil pointer that batched
fallbacks and not-found branches trip over. Tests set only the one
or two fields they care about; everything else no-ops.
var _ storage.Store = (*FakeStore)(nil) // compile-time drift check
The blank-ident assertion (see the blank-ident-assert memory) is
what makes this scale: adding a method to the interface breaks the
build at the fake until someone adds one field and one delegating
method — one file, and every existing test keeps compiling because
unset fields already mean "succeed quietly".
Conventions that keep the fake trustworthy:
- List methods return non-nil empty slices when unmocked, so
tests distinguish "not mocked" from "no rows" via
len. If the production store returnsnilfor zero rows (the naturalvar xs []T+ append shape), this is a deliberate divergence — write it in the fake's doc comment, and route tests that assert the nil-vs-empty JSON encoding to a real in-memory store (byob-storage.6) instead. - When a singular method grows a batched sibling (
Get→GetMany), default the batched fake method to loop over the singular func field. Existing tests that stub the singular keep working without touching every call site. - Scoping methods return the fake itself by default (
WithTx,WithScope→return f). Note in the doc comment that this bypasses production filtering — tests that need to observe the pin set the corresponding func field.
Tradeoffs: the fake is a real file that grows with the interface (~10 lines per method) — but it grows in one place, once, instead of in every test package. It records nothing by default: no call counts, no ordering — which is byob-testing.3 by construction. A test that genuinely needs to observe a call sets the field to a closure that appends to a local slice; the capability exists without being the default.
Design
// internal/storage/storagetest/fake.go
package storagetest
var _ storage.Store = (*FakeStore)(nil)
// FakeStore is a func-field fake for storage.Store. Unset fields
// return zero-value success; List* methods return non-nil empty
// slices (production returns nil for zero rows — tests asserting
// that encoding must use a real in-memory store).
type FakeStore struct {
SaveItemFunc func(context.Context, storage.Item) error
ListItemsFunc func(context.Context) ([]storage.Item, error)
GetItemFunc func(context.Context, string) (*storage.Item, error)
GetItemsFunc func(context.Context, []string) (map[string]*storage.Item, error)
CloseFunc func() error
// ... one field per method
}
func (f *FakeStore) SaveItem(ctx context.Context, it storage.Item) error {
if f.SaveItemFunc != nil { return f.SaveItemFunc(ctx, it) }
return nil
}
func (f *FakeStore) ListItems(ctx context.Context) ([]storage.Item, error) {
if f.ListItemsFunc != nil { return f.ListItemsFunc(ctx) }
return []storage.Item{}, nil // non-nil: "not mocked" ≠ "no rows"
}
// Get-one lookups default to the not-found sentinel, not (nil, nil):
// zero-value "success" here would poison every batched fallback and
// not-found branch with a nil *Item.
func (f *FakeStore) GetItem(ctx context.Context, id string) (*storage.Item, error) {
if f.GetItemFunc != nil { return f.GetItemFunc(ctx, id) }
return nil, storage.ErrNotFound
}
// GetItems routes through GetItemFunc when the batched field is
// unset, so tests written against the singular method survive the
// batching refactor. Not-found means "absent from the map"; the nil
// guard keeps a sloppy stub from planting nil values.
func (f *FakeStore) GetItems(ctx context.Context, ids []string) (map[string]*storage.Item, error) {
if f.GetItemsFunc != nil { return f.GetItemsFunc(ctx, ids) }
out := make(map[string]*storage.Item, len(ids))
for _, id := range ids {
it, err := f.GetItem(ctx, id)
if errors.Is(err, storage.ErrNotFound) { continue }
if err != nil { return nil, err }
if it != nil { out[id] = it }
}
return out, nil
}
// A test sets exactly what it needs:
st := &storagetest.FakeStore{
ListItemsFunc: func(ctx context.Context) ([]storage.Item, error) {
return []storage.Item{{Name: "alpha"}}, nil
},
}
f := &cmdutil.Factory{IOStreams: ios, Store: func() (storage.Store, error) { return st, nil }}