Day 17 — Fakes vs mocks

Updated

July 30, 2026

Day 17 — Fakes vs mocks

Stage II · ~3h (theory-heavy)
Goal: Drive tests with hand-written fakes behind small interfaces—distinguish fakes, stubs, mocks, and spies—and know when a real in-memory implementation is better than a mock framework.

Note

This volume prefers stdlib + hand fakes. Mock-generation tools exist; you do not need them to write excellent Go tests. Interfaces (Day 11) are the seam.

Why this day exists

Unit tests that hit the network, real clocks, or production databases are slow and flaky. The Go way:

  1. Depend on interfaces at the boundary
  2. Supply a fake in tests
  3. Keep fakes stateful and simple, not hyper-configured mock DSLs

Day 17 builds taste so Day 18’s module ships with tests others trust.


Theory 1 — Vocabulary

Term Meaning Typical use
Stub Returns canned values; little behavior “Always return this user”
Fake Working simplified implementation In-memory DB, map store
Mock Pre-programmed expectations; fails if calls differ Verify interaction order
Spy Records calls for later assertions “Was Save called once?”

In Go conversations, people often say “mock” for any test double. Prefer fake for map-backed stores—you will write those most.


Theory 2 — Interface as seam (review with purpose)

type UserRepository interface {
    Find(ctx context.Context, id string) (User, error)
    Save(ctx context.Context, u User) error
}

type Service struct {
    users UserRepository
    // mail Mailer
}

func (s *Service) Rename(ctx context.Context, id, name string) error {
    u, err := s.users.Find(ctx, id)
    if err != nil {
        return err
    }
    u.Name = name
    return s.users.Save(ctx, u)
}

Production wires Postgres; tests wire FakeUsers.


Theory 3 — Writing a good fake

type FakeUsers struct {
    ByID map[string]User
    // optional behavior knobs:
    FindErr error
    SaveErr error
}

func NewFakeUsers(users ...User) *FakeUsers {
    f := &FakeUsers{ByID: make(map[string]User)}
    for _, u := range users {
        f.ByID[u.ID] = u
    }
    return f
}

func (f *FakeUsers) Find(_ context.Context, id string) (User, error) {
    if f.FindErr != nil {
        return User{}, f.FindErr
    }
    u, ok := f.ByID[id]
    if !ok {
        return User{}, ErrNotFound
    }
    return u, nil
}

func (f *FakeUsers) Save(_ context.Context, u User) error {
    if f.SaveErr != nil {
        return f.SaveErr
    }
    if f.ByID == nil {
        f.ByID = make(map[string]User)
    }
    f.ByID[u.ID] = u
    return nil
}

var _ UserRepository = (*FakeUsers)(nil)

Properties of good fakes

  • Implement the same interface as production
  • Stateful enough to exercise real logic paths
  • Failure injection via fields (SaveErr)
  • No network, no sleep
  • Not more complex than the code under test

Theory 4 — Spy: record interactions

type SpyMailer struct {
    Messages []string
    Err      error
}

func (s *SpyMailer) Send(_ context.Context, to, body string) error {
    if s.Err != nil {
        return s.Err
    }
    s.Messages = append(s.Messages, to+":"+body)
    return nil
}

Assert:

if len(mail.Messages) != 1 {
    t.Fatalf("got %d mails", len(mail.Messages))
}

Use spies when side effects matter and the fake would otherwise hide them.


Theory 5 — When mocks become harmful

Smell Problem
Expect exact call order of every dependency Brittle; refactors break tests
Mocking concrete structs with codegen Heavy; interfaces were the point
200-line mock setup Design may need smaller interfaces
Asserting implementation details Test behavior/outcomes

Prefer: arrange fakes → act on service → assert state and returned errors.


Theory 6 — What not to fake

Keep real Why
Pure functions No seam needed
Stdlib parsers (strconv, json for your types) Already deterministic
Your own in-memory algorithms You are testing them
Fake Why
DB / HTTP / disk (sometimes) Speed/isolation
Clocks Determinism (interface Now() time.Time)
Email / SMS No external side effects
Randomness Seed or inject source

Theory 7 — Context in signatures (light)

Even if you ignore cancellation today, matching production signatures with context.Context as first param keeps fakes honest:

Find(ctx context.Context, id string) (User, error)

Deep context usage is Stage III. Today: accept ctx, ignore or check ctx.Err() optionally.


Worked examples bank

Example A — Service test with fake

func TestRename(t *testing.T) {
    repo := NewFakeUsers(User{ID: "1", Name: "Ada"})
    svc := NewService(repo)

    if err := svc.Rename(context.Background(), "1", "Augusta"); err != nil {
        t.Fatal(err)
    }
    u, err := repo.Find(context.Background(), "1")
    if err != nil {
        t.Fatal(err)
    }
    if u.Name != "Augusta" {
        t.Fatalf("name=%q", u.Name)
    }
}

Example B — Error path injection

func TestRenameSaveFails(t *testing.T) {
    repo := NewFakeUsers(User{ID: "1", Name: "Ada"})
    repo.SaveErr = errors.New("disk full")
    svc := NewService(repo)

    err := svc.Rename(context.Background(), "1", "X")
    if err == nil {
        t.Fatal("expected error")
    }
}

Example C — Not found

func TestRenameMissing(t *testing.T) {
    svc := NewService(NewFakeUsers())
    err := svc.Rename(context.Background(), "nope", "X")
    if !errors.Is(err, ErrNotFound) {
        t.Fatalf("got %v", err)
    }
}

Example D — Clock injection

type Clock interface {
    Now() time.Time
}

type fixedClock struct{ t time.Time }

func (f fixedClock) Now() time.Time { return f.t }

type realClock struct{}

func (realClock) Now() time.Time { return time.Now() }

Example E — Avoid over-mocking

// Weak test: only checks that Find was called — misses rename logic bugs.
// Strong test: checks saved user name and errors.Is paths.

Labs

Suggested workspace: ~/lab/90daysofx/go/day17

Lab 1 — Service + fake repository

mkdir -p ~/lab/90daysofx/go/day17
cd ~/lab/90daysofx/go/day17
go mod init example.com/day17

Build:

  1. Domain entity + repository interface
  2. Service methods that use the interface (at least two paths: success + error)
  3. FakeX with map storage + error injection
  4. Table-driven tests on the service

Lab 2 — Spy side effect

Add a Notifier interface. On successful rename (or create), notify. Spy records messages. Assert one notification on success and zero on failure.

Lab 3 — Compile-time check

var _ UserRepository = (*FakeUsers)(nil)
var _ UserRepository = (*PostgresUsers)(nil) // if you stub a type with panic methods

A skeleton Postgres type with panic("not implemented") methods is optional; the point is the interface contract.

Lab 4 — Judgment notes

In NOTES.md:

  • What you faked and why
  • What you kept real
  • One mock-style assertion you avoided

Common gotchas

Gotcha Fix
Fake that doesn’t implement full interface var _ I = (*Fake)(nil)
Shared fake state across subtests New fake per t.Run
Testing the fake more than the service Keep fakes thin
Interface too large to fake Split interface
Real sleep/network in unit tests Inject deps
Asserting call counts only Assert outcomes

Checkpoint

  • Fake implements production interface
  • Service tests use fake only
  • Error injection covered
  • Spy or notification assert optional but understood
  • Vocabulary: fake vs mock
  • Notes on judgment

Commit

git add .
git commit -m "day17: fakes spies service tests"

Tomorrow

Day 18 — Stage II gate: publishable-shaped module with public API, tests (tables + fakes), README, and idiomatic errors—ready to open Stage III concurrency.