Mocks vs Stubs vs Spies (Test Doubles) in JavaScript

HighIntermediateJavascript
Preparing for interviews?

Use guided tracks for structured prep, then practice company-specific question sets when you want targeted interview coverage.

Quick Answer

Explain the difference between mocks, stubs, and spies in unit testing. Describe when to use each, how to avoid brittle tests, and how dependency injection helps testability. Include an async example and common pitfalls.

Answer

Why this matters

In interviews and real code, testing isn't about writing 200 assertions—it’s about verifying behavior at the right boundary. Test doubles (mocks/stubs/spies) let you isolate a unit from slow, flaky, or external dependencies (network, storage, time, analytics).

Practical scenario

You have searchUsers(query) that calls an HTTP client and returns parsed JSON. In a unit test, you don’t want real network. You want to:

  • Stub the HTTP client to return a controlled response.
  • Spy on the client to assert it was called correctly.
  • Optionally mock the client to both define expectations and fail if they’re not met.

Test double

What it is

Use it when

Common smell

Spy

Records calls (args/call count)

You want to verify interactions at a boundary

Asserting exact call order everywhere

Stub

Returns fake data to drive a code path

You need deterministic input (no real network/time)

Stubbing internal implementation details

Mock

A stub + expectations ("must be called with X")

You want strict interaction contracts for critical boundaries

Over-mocking makes refactors painful

Rule of thumb: prefer stubs for inputs, spies for boundary verification, mocks only when strict contracts are worth the brittleness.
JAVASCRIPT
// Dependency injection makes testing simple.
// Production code:
export async function searchUsers(query, { fetchJson }) {
  if (!query) return [];
  const data = await fetchJson(`/api/users?q=${encodeURIComponent(query)}`);
  return Array.isArray(data) ? data : [];
}

// Unit test idea:
const calls = [];
const fetchJsonStub = async (url) => {
  calls.push(url);
  return [{ id: 1, name: 'Ada' }];
};

const users = await searchUsers('a', { fetchJson: fetchJsonStub });
// Assert output (preferred)
expect(users).toEqual([{ id: 1, name: 'Ada' }]);
// Assert boundary interaction (spy-like)
expect(calls).toEqual(['/api/users?q=a']);
                  

Common pitfalls

  • Over-mocking: tests fail on harmless refactors because they assert internal steps, not outcomes.
  • Leaking globals: stubbing global.fetch or Date.now without restoring breaks other tests.
  • Async flakiness: tests that depend on real timers or network ordering become flaky.
  • Missing error paths: only testing the happy path hides retries/timeouts/empty responses bugs.

Trade-off to mention in interviews

Unit tests are fast and isolate logic but can drift from reality if you mock too much. Integration tests catch wiring issues but are slower and harder to debug. A strong strategy uses both:

  • Unit tests for core logic + edge cases.
  • A few integration tests for the most important flows (auth, payments, critical UI paths).

Answer to land

"I prefer dependency injection so I can stub inputs and spy on boundaries. I assert outputs first, then interactions only when they’re part of the contract (e.g., a retry policy or analytics call). Mocks are useful for strict contracts, but I avoid over-mocking because it makes tests brittle."

Similar questions
Guides
11 / 61