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.
Mocks vs Stubs vs Spies (Test Doubles) in JavaScript
Use guided tracks for structured prep, then practice company-specific question sets when you want targeted interview coverage.
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 |
// 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.fetchorDate.nowwithout 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."