Mocking, Stubbing, and Test Isolation Questions
Isolating the unit under test from its dependencies. Covers mocks, stubs, fakes, and spies, when to use test doubles versus real dependencies, and controlling external services and time. Includes designing for isolation so tests are fast, deterministic, and focused.
Explain the differences between mocks, stubs, fakes, spies, and dummies. For each kind of test double, give a concrete example from a typical web application (an HTTP API call, a database, a message queue, or a cache) and a short guideline for when you would prefer that double over the others.
Sample Answer
Direct answer
A test double is a stand-in for a real dependency in a test. The five common kinds differ in what they do when called and what the test asserts about them: a dummy is passed around but never actually used, a stub returns canned data, a fake is a working but simplified implementation, a mock records calls so the test can verify they happened correctly, and a spy wraps a real object while also recording calls to it.
Structured elaboration
| Double | Behavior when called | What the test checks | Typical web-app example |
|---|---|---|---|
| Dummy | Does nothing meaningful; only fills a required parameter slot | Nothing about it directly | Passing a placeholder database-connection object into a constructor that requires one but is never queried on the code path under test |
| Stub | Returns a pre-programmed value | The return value the code under test produces | Making a "get exchange rate" HTTP client return a fixed 1.08 so a pricing calculation is deterministic |
| Fake | Runs real logic, just a lighter-weight implementation | The behavior/output, same as against the real thing | An in-memory key-value store used instead of a real Redis instance |
| Mock | Returns canned data AND records how it was called | That specific calls happened, with specific arguments, in the right order | Verifying that PaymentGateway.charge() was called exactly once with the correct amount |
| Spy | Wraps a real object, forwards calls through, and records them | Both real behavior and the interaction | Wrapping a real email-sending service so you can assert "send was called" while letting a test double catch the actual network call underneath |
The line between stub and mock is really about what the test asserts on: a stub only shapes the INPUT to the code under test (state verification), while a mock is used to verify OUTPUT in terms of interactions (behavior verification). The same test-double library (Mockito, unittest.mock, Sinon) is usually used to build all five; the taxonomy names roles, not library features.
Worked example
For a database example: a stub database client always returns a fixed list of three users for find_active_users(), regardless of what was inserted, so a report-generation test has predictable input. A mock database client would instead be used to verify that save(user) was called exactly once with a User object whose status field is "active", when testing the code that is supposed to activate a user. A fake database would be a real, lightweight in-memory dictionary-backed store that actually persists and retrieves records within the test, useful when the test needs realistic query behavior (like "insert then find") that a stub's fixed answer can't provide.
Trade-offs and pitfalls
Reach for a dummy when a dependency is only required to satisfy a constructor or function signature and is never actually exercised on the path under test; building anything more elaborate (a stub, a fake) for it would be wasted setup effort. Reach for a stub when you need to control an input; reach for a mock when the ACT of calling the dependency (with the right arguments, in the right order) is itself part of the behavior being tested, such as making sure a payment is charged exactly once. Reach for a spy when you want the dependency's real behavior to genuinely happen (unlike a mock, which fully replaces it) while still asserting that a specific interaction occurred, such as confirming a real cache was actually written to while also checking the write call's arguments. Overusing mocks where a stub would do makes tests brittle: every internal refactor that doesn't change observable behavior can still break a mock-heavy test, because the test is coupled to how the code calls its dependency rather than what the code produces.
In Python, you have a function fetch_user(user_id) that calls requests.get(...) against a live API and returns parsed JSON. Write a unit test using unittest and unittest.mock that stubs the call so the test runs entirely offline, and assert both the returned data and that the call was made with the expected URL.
Sample Answer
Direct answer
Patch requests.get for the duration of the test with unittest.mock.patch, configure the mock's return value to look like a real Response object, and assert both the parsed return value and the exact call arguments so the test proves the function calls the right URL, not just that it returns something.
Structured elaboration
from unittest.mock import patch, MagicMock
import requests
def fetch_user(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
import unittest
class FetchUserTest(unittest.TestCase):
@patch("requests.get")
def test_fetch_user_returns_parsed_json_and_calls_expected_url(self, mock_get):
mock_response = MagicMock()
mock_response.json.return_value = {"id": 42, "name": "Ada Lovelace"}
mock_get.return_value = mock_response
result = fetch_user(42)
self.assertEqual(result, {"id": 42, "name": "Ada Lovelace"})
mock_get.assert_called_once_with("https://api.example.com/users/42")
Executed with pytest:
py-sandbox/test_s17_fetch_user.py::FetchUserTest::test_fetch_user_returns_parsed_json_and_calls_expected_url PASSED
1 passed in 0.01s
@patch("requests.get") patches the attribute directly on the real requests module object, which is the correct target here because fetch_user calls requests.get(...) by looking up get on the requests module at call time; since there is only one requests module object in the process, patching its get attribute affects every caller, regardless of which file imported requests.
Worked example
Without assert_called_once_with, a bug where fetch_user accidentally hit .../users/{user_id}/profile or forgot to URL-encode the id would still pass a test that only checks the returned data (since the mock returns the same canned JSON no matter what URL it's called with). Asserting on the exact call arguments is what actually proves the function constructs the right request, not just that it can parse a response.
Trade-offs and pitfalls
A common mistake is patching the WRONG target, @patch("some_module_where_requests_is_imported.requests.get") only works if that's genuinely how the lookup resolves; when the code does import requests and then calls requests.get(...), patching the module attribute directly ("requests.get") is correct and more robust to refactors than patching a re-exported name. This test also completely bypasses whatever the real API would do, which is the point for a fast unit test, but it means it teaches nothing about whether https://api.example.com is even reachable or returns the shape assumed here; that's a job for a smaller number of higher-fidelity tests, not this one.
Explain the roles of unit, integration, and end-to-end tests in a delivery pipeline. For each layer, describe what should typically be mocked versus run against a real dependency, and walk through a concrete example for a payment flow showing where mocks belong and why.
Sample Answer
Direct answer
As a rough default: unit tests mock everything outside the function or class under test; integration tests run against real (or lightly virtualized) adjacent components but still mock the furthest-out third parties; end-to-end tests run against real dependencies wherever it's safe and affordable to do so.
Structured elaboration
- Unit tests: the fastest, most numerous layer. Everything the unit under test calls, database, network, other services, gets mocked, so the test isolates and exercises only the logic in that one unit.
- Integration tests: verify that two or more of YOUR OWN components wire together correctly (your service and your database, your service and an internal message queue). Real (or a very high-fidelity fake of) your own infrastructure is used here, but external third parties are usually still mocked or virtualized, since the point is validating your own integration code, not the third party's uptime.
- End-to-end tests: exercise a full user-facing flow across real systems. Real external dependencies are used where safe (sandboxed accounts, staging environments); anything unsafe, costly, or nondeterministic to call for real (an actual bank transfer, an actual SMS bill) still gets stubbed even at this layer.
Worked example
For a payment flow: at the unit level, mock the PaymentGateway interface entirely and test that the order service calls charge() with the right amount and handles a thrown exception by not saving the order. At the integration level, run the order service against a real local database to confirm the SQL actually persists correctly, while still mocking the payment gateway (a third party, out of scope for this layer). At the end-to-end level, run the full checkout flow against the payment gateway's real sandbox environment, so the test confirms the actual network contract and response shapes match what your code expects, without charging a real card.
Trade-offs and pitfalls
The three-layer split breaks down if a team pushes everything into unit tests with mocks and skips the integration layer entirely: unit tests can all pass while the wiring between your own components is broken, because no test ever exercised your actual database or actual message-queue configuration. Conversely, pushing too much into end-to-end tests makes the suite slow and flaky without necessarily catching bugs any earlier or more precisely than a well-placed integration test would.
Design tests to validate the retry and circuit-breaker behavior of a service that calls an external HTTP API. Describe how you would use a mock or a local controllable stub server to produce timeouts, slow responses, and server errors, and how you would assert on circuit-breaker state transitions and retry counts. Note any edge cases your tests should cover.
Sample Answer
Direct answer
Drive a controllable stub HTTP client through a scripted sequence of timeouts, slow responses, and server errors, then assert on both the retry count actually used and the circuit breaker's state transitions (closed to open, and open to half-open to closed again after a cooldown), rather than only checking the final result.
Structured elaboration
import time
class TransientError(Exception): pass
class CircuitOpenError(Exception): pass
class StubHttpClient:
def __init__(self, script):
self.script = list(script)
self.calls = 0
def get(self, path):
self.calls += 1
behavior = self.script.pop(0) if self.script else "ok"
if behavior == "timeout":
raise TimeoutError(f"timed out calling {path}")
if behavior == "500":
raise TransientError(f"server error calling {path}")
return {"status": 200, "path": path}
class CircuitBreaker:
def __init__(self, failure_threshold=3, cooldown_seconds=30, clock=time.monotonic):
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self.clock = clock
self.state = "closed"
self.consecutive_failures = 0
self.opened_at = None
def before_call(self):
if self.state == "open":
if self.clock() - self.opened_at >= self.cooldown_seconds:
self.state = "half_open"
else:
raise CircuitOpenError("circuit is open")
def record_success(self):
self.consecutive_failures = 0
self.state = "closed"
def record_failure(self):
self.consecutive_failures += 1
if self.state == "half_open" or self.consecutive_failures >= self.failure_threshold:
self.state = "open"
self.opened_at = self.clock()
def call_with_retry(client, breaker, path, max_retries=2):
breaker.before_call()
attempts_used = 0
while True:
try:
result = client.get(path)
breaker.record_success()
return result, attempts_used
except (TimeoutError, TransientError):
if attempts_used >= max_retries:
breaker.record_failure()
raise
attempts_used += 1
class FakeClock:
def __init__(self, start=0.0):
self.now = start
def __call__(self):
return self.now
def advance(self, seconds):
self.now += seconds
def test_retries_then_succeeds_records_correct_retry_count():
client = StubHttpClient(["timeout", "500"])
breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=30, clock=FakeClock())
result, retries_used = call_with_retry(client, breaker, "/thing", max_retries=2)
assert client.calls == 3
assert retries_used == 2
assert result == {"status": 200, "path": "/thing"}
assert breaker.state == "closed"
def test_circuit_opens_after_threshold_consecutive_failures():
clock = FakeClock(start=0.0)
client = StubHttpClient(["500", "500", "500", "ok"])
breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=30, clock=clock)
for _ in range(3):
try:
call_with_retry(client, breaker, "/thing", max_retries=0)
assert False, "expected TransientError to propagate"
except TransientError:
pass
assert breaker.state == "open"
calls_before_reject = client.calls
try:
call_with_retry(client, breaker, "/thing", max_retries=0)
assert False, "expected CircuitOpenError while circuit is open"
except CircuitOpenError:
pass
assert client.calls == calls_before_reject # rejected without ever reaching the stub client
clock.advance(31)
result, _ = call_with_retry(client, breaker, "/thing", max_retries=0)
assert result == {"status": 200, "path": "/thing"}
assert breaker.state == "closed"
if __name__ == "__main__":
test_retries_then_succeeds_records_correct_retry_count()
test_circuit_opens_after_threshold_consecutive_failures()
print("ALL ASSERTIONS PASSED")
Executed with python3: both test_retries_then_succeeds_records_correct_retry_count and test_circuit_opens_after_threshold_consecutive_failures pass, printing ALL ASSERTIONS PASSED, with an injected FakeClock used to advance past the cooldown window deterministically (no real 30-second wait in the test).
Worked example
The retry test scripts the stub to fail twice (a timeout, then a server error) before succeeding, and asserts exactly 3 calls were made and the retry loop used 2 retries, proving the retry count is exact rather than "at least some retries happened." The circuit-breaker test scripts 3 consecutive failures, asserts the breaker transitions to open, then asserts a call attempted before the cooldown elapses is rejected WITHOUT even reaching the stub client (client.calls doesn't increase), which is the behavior that actually protects a struggling downstream dependency from being hammered while it's unhealthy. Advancing a fake clock past the cooldown and scripting a success proves the breaker correctly moves to half-open and then closes again.
Edge cases
Exponential-backoff overflow: as retries increase, a naive delay = base * 2^attempt can grow unbounded; the retry loop should cap the delay at a sane maximum rather than letting it grow indefinitely on a long failure streak. Idempotency: a retried call must not have a different effect than a single call, if the underlying operation isn't naturally idempotent (like a payment charge), the retry wrapper needs an idempotency key or another safeguard so retrying a timed-out call can't result in the operation happening twice.
Trade-offs and pitfalls
Asserting only on the final return value ("it eventually succeeded") would miss a broken retry count (say, retrying 10 times instead of the intended 2) or a broken circuit breaker that never actually opens; asserting on client.calls and on breaker.state directly is what actually verifies the RESILIENCE mechanisms, not just the happy path they're protecting. Using a real clock and a real time.sleep in this kind of test would make it either slow (waiting out real cooldowns) or flaky (racing against real timing); injecting the clock is what makes the cooldown-based state transition deterministic and fast.
Describe test isolation in the context of automated testing for a microservice. Explain why isolation matters, list common sources of test interference, and outline the practices you would enforce in CI to keep tests isolated.
Sample Answer
Direct answer
Test isolation means one test's setup, execution, and teardown cannot affect another test's outcome; it matters because a suite where tests interfere with each other produces failures that depend on run order or which tests happened to run before it, which destroys the ability to trust a single test's result in isolation.
Structured elaboration
Common sources of interference in a microservice's test suite:
- Shared mutable state: a database, an in-memory cache, or a static/global variable that one test writes to and another test reads, so the second test's outcome depends on what the first test did.
- External resources with real state: a shared file, a shared queue, or a shared third-party sandbox account whose state persists across test runs.
- Nondeterministic inputs: the current wall-clock time or a source of randomness that produces a different value each run, so the same test can pass or fail depending on when or how many times it's run.
- Order dependence: a test that only passes because an earlier test happened to leave the system in a particular state, and fails if run alone or in a different order.
Practices to enforce isolation in CI: give each test (or each test worker, for parallel execution) its own database transaction that gets rolled back, or its own ephemeral schema/namespace; inject the clock and any randomness sources instead of reading them from the environment directly, so tests can pin them; run tests that must touch shared infrastructure serially or with explicit locking rather than assuming parallel safety by default; and treat "passes alone but fails in the full suite" as a bug in the test, not a fluke to re-run away.
Worked example
A microservice's order-processing tests all write to the same test database table without transactions. Test A creates an order with id 1001; test B, checking "creating a duplicate order id fails," happens to reuse 1001 and only passes because test A ran first and left that row behind. Wrapping each test in a transaction that rolls back at teardown, or generating a fresh unique id per test, removes the order dependence entirely: each test now sets up exactly the state it needs and leaves nothing behind for the next one.
Trade-offs and pitfalls
Enforcing isolation has a real cost (transactions, ephemeral environments, injected clocks add setup code), and it is tempting to skip it while the suite is small. The cost of NOT doing it compounds: as the suite grows, order-dependent tests accumulate silently until a routine reordering (parallelizing the suite, or adding a new test earlier in the file) causes a wave of failures with no obvious cause, which is far more expensive to untangle after the fact than to prevent up front.
Unlock Full Question Bank
Get access to all 23 Mocking, Stubbing, and Test Isolation interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.