Skip to content
yutils

How Mocking Actually Works

Stub vs mock vs spy vs fake (Gerard Meszaros's four), why over-mocking gives you 100% coverage of nothing real, when to fake the network vs the DB, and how test doubles become coupling to internal implementation.

~9 min read

Tests replace external dependencies — DB, HTTP, file IO, time — with fakes. "Just mock it" sounds simple, but test doubles come in four kinds (stub / mock / spy / fake), each with its own cost. This guide covers Gerard Meszaros's taxonomy, the mocking traps, and why "100% coverage" can still mean "no real safety".

Test Doubles — Four Kinds (Gerard Meszaros)

1. Stub — Returns a Predetermined Value

class UserServiceStub {
  findUser(id) {
    return {id: 1, name: "Alice"};  // always the same
  }
}

test("displays user name", () => {
  const view = renderUserView(new UserServiceStub());
  assert(view.contains("Alice"));
});

Use: pretend this value comes here, verify downstream logic.
No call verification — only the return value matters.

2. Mock — Verifies Calls Too

const mockEmail = mock();
processOrder(order, mockEmail);
expect(mockEmail).toHaveBeenCalledWith(
  "alice@example.com",
  expect.stringContaining("confirmation")
);

Use: verify "this dependency was called with these arguments".
Great for side effects (email, payment, ...).

3. Spy — Real Object + Call Tracking

const realLogger = new ConsoleLogger();
const spy = jest.spyOn(realLogger, "warn");

doWork(realLogger);

expect(spy).toHaveBeenCalledTimes(2);
// realLogger still prints to the real console (default)

Use: keep real behavior, just observe calls.
Or partially mock one method (spy + mockReturnValue).

4. Fake — A Working Simplified Implementation

class InMemoryUserRepo {
  users = new Map();
  save(u) { this.users.set(u.id, u); }
  find(id) { return this.users.get(id); }
}

// Same API as the real DB, lives in memory
test("user saved and findable", () => {
  const repo = new InMemoryUserRepo();
  repo.save({id: 1, name: "Alice"});
  expect(repo.find(1).name).toBe("Alice");
});

Use: a lightweight stand-in for heavy / slow dependencies
(DB, queue). Same family as testcontainers / sqlite in-memory.

Over-Mocking — "100% Coverage, No Real Safety"

function createOrder(user, items) {
  const total = items.reduce((s, i) => s + i.price * i.qty, 0);
  return {user, items, total};
}

test("creates order", () => {
  const user = mock();
  const items = [mock()];

  // items[0].price, qty are mocks — they don't work
  // (or you'd have to mock items[0].price too)

  const order = createOrder(user, items);
  expect(order.user).toBe(user);  // ← tautology
  expect(order.items).toBe(items);  // ← tautology

  // total? Everything is mocked → meaningless
});

Problems:
- 100% of lines executed
- But actual computation never verified (inputs & outputs all mocked)
- Coverage tool says green, real bugs slip through

Mocking — Right Boundaries

✓ Worth Mocking

  • External network (HTTP APIs, third-party services)
  • Time (Date.now, setTimeout)
  • Random (Math.random)
  • Filesystem (cross-platform differences)
  • Expensive side effects (email, SMS, payment)

✗ Avoid Mocking

  • Plain functions / pure logic (calling directly is simpler)
  • Data structures (fixture objects beat mocks)
  • Your own internal modules (implementation coupling ↑)
  • DB — fake (in-memory) or testcontainer is safer

Time / Random Mock — Special Territory

// time mock
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-05-25T00:00:00Z"));

test("expires after 1 hour", () => {
  const session = createSession();
  jest.advanceTimersByTime(60 * 60 * 1000 + 1);
  expect(session.expired()).toBe(true);
});

// random mock — avoids non-determinism
jest.spyOn(Math, "random").mockReturnValue(0.5);

→ Determinism for tests. Random-driven logic becomes reproducible.

Mock-Driven Implementation Coupling

// Code
async function getUser(id) {
  const cached = await cache.get(id);
  if (cached) return cached;
  const user = await db.users.find(id);
  await cache.set(id, user);
  return user;
}

// Test (over-specified)
test("fetches user", async () => {
  cache.get.mockResolvedValue(null);
  db.users.find.mockResolvedValue({id: 1, name: "A"});
  cache.set.mockResolvedValue();

  const user = await getUser(1);

  expect(cache.get).toHaveBeenCalledWith(1);
  expect(cache.get).toHaveBeenCalledTimes(1);  // ← implementation coupled
  expect(db.users.find).toHaveBeenCalledWith(1);
  expect(cache.set).toHaveBeenCalledWith(1, {id: 1, name: "A"});
  expect(user.name).toBe("A");
});

Problems:
- Caching refactored (e.g. cache.getOrSet) → test breaks
- Real verification = "returns the user", but 5 assertions
- Maintenance burden every time implementation changes

Better:
test("returns the user", async () => {
  db.users.find.mockResolvedValue({id: 1, name: "A"});
  const user = await getUser(1);
  expect(user.name).toBe("A");
});

Choosing a Test Double

SituationTool
Only the return value mattersStub
The call itself is being verified (email, payment)Mock
Real behavior + call tracking togetherSpy
Lightweight in-memory replacementFake
External HTTP APInock / msw (HTTP-level mock)
DBtestcontainer / sqlite in-memory (fake)
Timejest.useFakeTimers / sinon

Common Pitfalls

  • Mocking plain functions — importing and calling directly is simpler and safer.
  • Verifying implementation details — "cache.get was called once" — over-specified. Verify behavior only.
  • Forgetting mock reset — state leaks across tests → flaky. Use jest.clearAllMocks() in beforeEach.
  • Excessive auto-mocking — when every module is auto-mocked, real integration gaps become invisible. Prefer explicit mocks.
  • Stubs instead of real network — only production breaks when the HTTP API changes. Reinforce with contract tests (separate guide).

Wrap-up

Mocking at its core — replace hard-to-control dependencies with controllable fakes. Overdoing it couples to implementation and leaves real integration unverified. Pick test doubles deliberately among the four kinds.

Practical: mock only external boundaries (HTTP, time, random); call internal logic directly. DB — fake (in-memory) or testcontainer. Verify behavior (output-based), avoid implementation verification (call counts, etc.).

Back to guides