Mocking: Friend or Foe?

Mocking can unlock fast, focused tests — or it can create brittle, misleading ones. The difference is what you mock and why.

TL;DR

  • Friend: Mock boundaries you own or don't control (network, clock, random, OS, external APIs) to make tests fast and deterministic.
  • Foe: Mock internal collaborators just to verify wiring or call counts; you're testing implementation, not behavior.
  • Prefer fakes (small in-memory implementations) over strict mocks that police internals.

When Mocking Helps ✅

  1. Non-determinism: time, randomness, UUIDs → inject a clock/PRNG/ID generator.
  2. Expensive I/O: HTTP, DB, queues → use test servers, in-memory DBs, or fakes.
  3. Third-party boundaries: Stripe, Slack, S3 → contract tests + mock/fake clients.
  4. Difficult-to-trigger failures: simulate timeouts, 500s, partial failures.
  5. Safety: Prevent side effects (emails, charges, deletions) in tests.

When Mocking Hurts 🚫

  • You verify call counts and method order instead of outcomes.
  • You mirror implementation details (SQL strings, private helpers).
  • Tests become green but useless (they pass even when behavior is broken).
  • Fragility: harmless refactors break tests; developers stop trusting the suite.

Replace Mocks with Fakes (Preferably)

Fake: a lightweight in-memory substitute that behaves like the real thing at the API level.

Pros: expressive, fewer expectations, refactor-safe, documents the contract.

Example (Go) — User store

// Interface boundary
type UserStore interface { 
    Save(ctx context.Context, u User) error
    Find(ctx context.Context, email string) (User, error) 
}

// Fake implementation for tests
type fakeUserStore struct { 
    saved []User
    byEmail map[string]User
    err error 
}

func (f *fakeUserStore) Save(ctx context.Context, u User) error { 
    if f.err != nil { return f.err }
    if f.byEmail == nil { f.byEmail = map[string]User{} }
    f.byEmail[u.Email] = u
    f.saved = append(f.saved, u)
    return nil 
}

func (f *fakeUserStore) Find(ctx context.Context, email string) (User, error) { 
    u, ok := f.byEmail[email]
    if !ok { return User{}, sql.ErrNoRows }
    return u, nil 
}

Use this fake to assert behavioral outcomes (what was saved / retrieved), not the internal SQL the repo used.

HTTP: Mock Client vs Test Server

Bad (over-mocking HTTP calls)

Stubbing low-level Do(req) calls with brittle expectations on URLs/headers/body formatting.

Better (contract-level test with a real server)

Go

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/v1/pay" { 
        t.Fatalf("unexpected path: %s", r.URL.Path) 
    }
    io.WriteString(w, `{"status":"ok","id":"123"}`)
}))
defer srv.Close()

cli := NewPaymentsClient(srv.URL)
res, err := cli.Pay(ctx, 499, "usd")
if err != nil { t.Fatal(err) }
if res.Status != "ok" { t.Fatalf("status: %s", res.Status) }

Node/JS (using node:test + undici)

import { test } from 'node:test';
import assert from 'node:assert';
import { createServer } from 'http';

test('charges successfully', async () => {
  const srv = createServer((req,res) => { 
    res.end(JSON.stringify({status:'ok', id:'123'})); 
  });
  await new Promise(r => srv.listen(0, r));
  const url = `http://127.0.0.1:${srv.address().port}`;

  const client = new PaymentsClient(url);
  const res = await client.pay(499, 'usd');
  assert.equal(res.status, 'ok');
  srv.close();
});

You're exercising real HTTP semantics without the network. No brittle spy-on-internals.

Time: Fake the Clock, Don't Sleep

Bad

time.Sleep(2 * time.Second)
if got := svc.TokenExpired(u); !got { 
    t.Fatal("expected expired") 
}

Good

// Inject clock
type Clock interface{ Now() time.Time }

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

svc := Service{Clock: fixedClock{t: time.Date(2025,8,1,0,0,0,0,time.UTC)}}
// advance by setting a new fixed time
svc.Clock = fixedClock{t: time.Date(2025,8,2,0,0,0,0,time.UTC)}
if !svc.TokenExpired(u) { t.Fatal("expected expired") }

Deterministic, instant, no flakes.

Randomness/IDs: Inject Generators

type IDGen interface{ New() string }

type seqGen struct{ n int }
func (g *seqGen) New() string { 
    g.n++
    return fmt.Sprintf("id-%d", g.n) 
}

svc := Service{IDs: &seqGen{}}
order := svc.CreateOrder(...)
if !strings.HasPrefix(order.ID, "id-") { 
    t.Fatal("bad id") 
}

Decision Guide

Ask these before mocking:

  1. Am I at a boundary? If yes, mock/fake is legit. If no, prefer real code and test behavior.
  2. Will this tie me to implementation details? If yes, redesign the test.
  3. Can a fake or test server express this more robustly? Prefer that.
  4. Does the failure message remain actionable? If not, rethink assertions.

DOs and DON'Ts

DO

  • Mock boundaries: network, clock, randomness, filesystem.
  • Use fakes to capture outcomes.
  • Write contract/black-box assertions.
  • Add a few error-path tests (timeouts, 500s, retries).

DON'T

  • Assert call counts/orders unless they define the contract.
  • Peek at private SQL/implementation details.
  • Sleep for timing; fake time or signal readiness.
  • Overuse global mocks that leak across tests.

Golden Rules

👉 Mock boundaries; fake collaborators; assert behavior.

👉 If a harmless refactor breaks your test, you mocked the wrong thing.