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 throughMocking — 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
| Situation | Tool |
|---|---|
| Only the return value matters | Stub |
| The call itself is being verified (email, payment) | Mock |
| Real behavior + call tracking together | Spy |
| Lightweight in-memory replacement | Fake |
| External HTTP API | nock / msw (HTTP-level mock) |
| DB | testcontainer / sqlite in-memory (fake) |
| Time | jest.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.).