Day 17 — Fakes vs mocks
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.
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:
- Depend on interfaces at the boundary
- Supply a fake in tests
- 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/day17Build:
- Domain entity + repository interface
- Service methods that use the interface (at least two paths: success + error)
FakeXwith map storage + error injection
- 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 methodsA 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.