Why this matters
As an API Engineer, your services talk to databases, caches, message queues, payment gateways, and other microservices. If your unit tests hit real dependencies, they become slow, flaky, and hard to debug. Mocking dependencies lets you:
- Isolate a unit of code and get instant feedback.
- Simulate hard-to-reproduce scenarios: timeouts, retries, 500s, malformed payloads.
- Test error handling and logging without impacting real systems.
- Ship changes with confidence and maintain a stable CI pipeline.
Concept explained simply
Mocking replaces real collaborators with controlled stand-ins.
- Stub: returns canned values. No behavior verification.
- Mock: programmable stub that can also verify interactions (calls, arguments, count).
- Fake: lightweight working implementation (e.g., in-memory repository).
- Spy: records what happened; assertions can be made afterward.
State vs behavior:
- State-based testing: assert outputs/returned values.
- Behavior-based testing: assert that certain calls happened (e.g., retry method called 3 times).
Mental model
Think of your system as concentric circles:
- Core logic (pure, no I/O) — test directly, no mocks.
- Boundary adapters (HTTP, DB, queues, time, random) — mock or use fakes in unit tests.
- Integration acceptance — confirm wiring with a few higher-level tests.
Trade-offs triangle: isolation, determinism, latency. Mocking optimizes isolation and determinism for fast feedback.
When not to mock
- Do not mock the module you are testing (SUT). Mock its dependencies.
- Prefer real code for simple value objects or pure functions.
- Use integration/contract tests at service boundaries to prevent drift from reality.
Worked examples
Example 1 — Python: Mock an HTTP call and a retry
# payment.py
import requests
def charge(card_token, amount, http=requests):
for attempt in range(3):
try:
resp = http.post("https://pay.example/charge", json={"card": card_token, "amount": amount}, timeout=2)
if resp.status_code == 200 and resp.json().get("ok"):
return {"status": "ok"}
except requests.exceptions.Timeout:
pass
return {"status": "failed"}
# test_payment.py
from unittest import mock
import payment
def test_charge_retries_then_fails():
http = mock.Mock()
# First two attempts timeout, third returns 500
http.post.side_effect = [requests.exceptions.Timeout(), requests.exceptions.Timeout(), mock.Mock(status_code=500, json=lambda: {"ok": False})]
result = payment.charge("tok_123", 5000, http=http)
assert result == {"status": "failed"}
assert http.post.call_count == 3
Example 2 — Node.js (Jest): Mock Redis and the clock
// cache.js
module.exports = function makeCache(client, clock = { now: () => Date.now() }) {
return {
setWithTTL: async (key, value, ttlMs) => {
const expiresAt = clock.now() + ttlMs;
await client.set(key, JSON.stringify({ value, expiresAt }));
},
isExpired: async (key) => {
const raw = await client.get(key);
if (!raw) return true;
const { expiresAt } = JSON.parse(raw);
return clock.now() >= expiresAt;
}
};
};
// cache.test.js
const makeCache = require('./cache');
test('sets TTL using injected clock', async () => {
const client = { set: jest.fn().mockResolvedValue('OK'), get: jest.fn() };
const clock = { now: () => 1_000 }; // fixed time
const cache = makeCache(client, clock);
await cache.setWithTTL('k', 'v', 500);
expect(client.set).toHaveBeenCalledTimes(1);
const payload = JSON.parse(client.set.mock.calls[0][1]);
expect(payload.expiresAt).toBe(1_500);
});
Example 3 — Java (Mockito): Mock repository and capture arguments
// UserService.java
public class UserService {
private final UserRepo repo;
private final Mailer mailer;
public UserService(UserRepo repo, Mailer mailer) { this.repo = repo; this.mailer = mailer; }
public void register(String email) {
if (repo.exists(email)) throw new IllegalStateException("taken");
User u = new User(email);
repo.save(u);
mailer.sendWelcome(email);
}
}
// UserServiceTest.java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock UserRepo repo;
@Mock Mailer mailer;
@InjectMocks UserService service;
@Test void saves_and_sends_mail() {
when(repo.exists("a@b.com")).thenReturn(false);
service.register("a@b.com");
ArgumentCaptor cap = ArgumentCaptor.forClass(User.class);
verify(repo).save(cap.capture());
assertEquals("a@b.com", cap.getValue().getEmail());
verify(mailer).sendWelcome("a@b.com");
verifyNoMoreInteractions(repo, mailer);
}
}
Setup patterns that make mocking easy
- Dependency injection: accept adapters/clients via constructor or function parameter.
- Wrap globals: create tiny interfaces for time, uuid, random, filesystem; inject them.
- Adapter pattern: hide vendor SDKs behind your interface to limit mocking surface.
- Deterministic inputs: seed random; inject fixed clocks; freeze UUID for tests.
- Use fakes for complex state: in-memory repo or fake queue for unit tests.
Smells and anti-patterns
- Over-mocking: asserting every tiny call. Prefer asserting outcomes at boundaries.
- Mocking what you own internally: test real collaborators if they are fast and pure.
- Leaky tests: duplicating production logic in mocks.
- Flaky timers: not controlling timeouts/sleeps.
Step-by-step: adding mocks to legacy code
- Identify the seam: where the function talks to I/O (HTTP, DB, time).
- Extract an interface or parameter for that collaborator.
- Pass the real dependency in production; pass a mock/fake in tests.
- Cover happy path with a stubbed success.
- Add error and timeout scenarios; verify retries/logging as needed.
- Refactor to reduce behavior verification if state assertions suffice.
Exercises (do these now)
Mirror of the exercises below. Aim for fast, deterministic tests. After completing, check off:
- Created mocks/stubs without touching real network or disk.
- Verified at least one behavior (call count or args) only where meaningful.
- Used dependency injection (parameters or constructors).
- Controlled time or randomness for determinism.
- Wrote one failing test first, then made it pass.
Exercise 1 — Node.js/Jest: Stub HTTP client
// userClient.js
async function getUser(http, id) {
const res = await http.get(`/users/${id}`);
const { id: userId, email, name } = res.data;
return { id: userId, email, displayName: name };
}
module.exports = { getUser };
Write a Jest test that stubs http.get to resolve with { data: { id: 7, email: "x@y.com", name: "A B" } } and asserts the DTO is { id: 7, email: "x@y.com", displayName: "A B" }.
Exercise 2 — Python: Mock time and random
# token.py
import random, time
def issue_token(rng=random, clock=time):
tid = f"t_{rng.randint(100,999)}"
return {"id": tid, "exp": int(clock.time()) + 60}
Write a test that injects fakes: rng with randint() returning 123, and clock with time() returning 1000. Assert id == "t_123" and exp == 1060.
Exercise 3 — Java/Mockito: Verify interactions
// Service
public boolean ensureUser(String email, UserRepo repo, Logger log) {
if (repo.exists(email)) { log.info("exists"); return false; }
repo.save(new User(email));
log.info("created");
return true;
}
Write a test that stubs repo.exists to true, verifies save is never called, and log.info("exists") is called once.
Common mistakes and self-check
- Mocking the system under test instead of its dependencies.
- Hardcoding time/randomness causing flaky tests.
- Asserting every internal call (brittle). Prefer outcome assertions.
- Leaving network calls in unit tests.
- Mocks returning unrealistic data (drift). Use representative payloads.
Self-check questions
- Can this test fail without any code change? If yes, what non-determinism remains?
- Is there a simpler state assertion instead of verifying calls?
- Does the mock reflect a plausible real-world response?
- Are integration tests covering the real boundary elsewhere?
Practical projects
- HTTP adapter: Build a small client for a public-style API (e.g., payments). Unit-test it with mocked HTTP for success, 4xx, 5xx, and timeouts.
- Clock + UUID wrapper: Create interfaces for time and id generation; refactor a feature to inject them and make tests deterministic.
- In-memory fake repo: Replace DB calls with a fake for unit tests; keep one integration test with a real DB or container.
- Retry + backoff: Implement retry logic; test with mocks verifying retry count and jitter via injected RNG.
Who this is for
- API Engineers building services that call external systems.
- Backend devs who want fast, reliable unit tests.
- Engineers improving CI stability and coverage.
Prerequisites
- Comfortable writing unit tests in at least one language (Python, Node.js, or Java).
- Basic understanding of HTTP and I/O operations.
- Familiarity with dependency injection patterns.
Learning path
- Unit testing fundamentals (assertions, test structure).
- Mocking dependencies (this lesson).
- Contract tests for service boundaries.
- Integration tests with real components where needed.
- Observability: logs and metrics assertions where appropriate.
Next steps
- Refactor two existing tests to remove real network calls.
- Introduce clock/uuid wrappers in your codebase.
- Add at least one contract test to validate real payloads.
Mini challenge
Your endpoint uses an email provider SDK. Write a unit test that:
- Injects the email client as a dependency.
- Stubs a transient failure on first call and success on second.
- Asserts your service retries once and returns a success result.
Tip: Use a mock with side effects to simulate the failure then success.
Quick Test is available below for everyone. Only logged-in users will see saved progress.