byob-go-cli

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 returns nil for zero rows (the natural var 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 (GetGetMany), 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, WithScopereturn 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 }}