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.
Compare the main strategies for virtualizing an external service in tests: hand-written contract-based stubs, a standalone mock server generated from an API contract, and network-level record-and-replay of real traffic. For each, discuss maintenance overhead, fidelity to production behavior, and how it scales when many tests run in CI. How would you decide it is safe to rely on one of these instead of the real dependency?
Sample Answer
Direct answer
Contract-based stubs, generated mock servers, and record-and-replay virtualization trade off differently on maintenance cost, fidelity, and scalability: hand-written contract stubs are cheapest to write but drift fastest, a mock server generated from a real API contract stays truer to the interface with less manual upkeep, and record-and-replay captures real traffic faithfully but is the most brittle to any change in the real service.
Structured elaboration
- Hand-written contract-based stubs: a developer writes the expected request/response pairs directly. Cheap to start, fast to run, but nothing keeps them honest, if the real service's contract changes, the stub keeps returning the old shape and tests keep passing against a lie.
- Standalone mock server generated from an API contract (for example, from an OpenAPI spec) via a tool like WireMock: the mock server's shape is derived mechanically from a document that is, ideally, kept current alongside the real API. This reduces manual drift risk versus hand-written stubs, at the cost of needing the contract itself to be trustworthy and current, and some setup/startup cost since it's a separate running process rather than an in-process object. Configuring stateful scenarios (a resource that behaves differently on a second call) and keeping the generated stubs in sync with schema changes are the ongoing maintenance tasks.
- Network-level record-and-replay: real traffic is captured once against the actual service, then replayed verbatim in tests. Highest fidelity to actual real-world behavior at the moment of recording, but the most brittle over time: any change to the real service (even a benign one) can make the recording stale, and non-deterministic fields in the recorded response (timestamps, request ids) need explicit handling or the replay will fail on exact-match comparisons. In CI it scales about as well as an in-process stub, since replay usually reads from local fixture files with no separate server process to start, but the fixture files themselves accumulate in the repository over time and must be scoped per test (not shared globally) so one test's replayed interaction cannot be consumed or exhausted by another test running in parallel.
- A fourth point on the spectrum, worth naming: spinning up a real but disposable instance of the dependency (a throwaway container running the actual service, or a sandboxed real instance) trades away some speed and isolation for the highest possible fidelity, and is most appropriate when the dependency is your own team's service rather than a genuine third party.
Deciding it's safe to rely on one of these instead of the real dependency comes down to how the fidelity gap is monitored: pairing whichever technique you choose with periodic revalidation against the real service (a scheduled job that diffs the contract, or an occasional smoke test against the real dependency) is what actually keeps any of the three honest over time.
Worked example
A team virtualizing a shipping-rates API starts with hand-written stubs for speed, then migrates to a WireMock server generated from the provider's published OpenAPI spec once the hand-written stubs drift and cause a production incident (the real API added a required field the stub never returned). They still keep one recorded-and-replayed interaction from a real sandbox call specifically to catch response-shape drift the OpenAPI spec itself might miss (an undocumented field the provider actually sends), and schedule a monthly job that re-records it and diffs against the version checked into source control.
Trade-offs and pitfalls
Startup cost matters in CI: an in-process mock or stub is essentially free to instantiate per test, while a standalone mock server has real startup latency and, if shared across parallel test workers, needs per-test isolation (a unique port, a request-scoped state key) to avoid cross-test interference. UI/e2e tests calling out to a virtualized dependency have their own practical concerns, realistic response shapes, managing test data and auth tokens the mock needs to accept, and keeping the mock layer updated as the real API evolves are ongoing work, not a one-time setup cost. Isolating tests from a genuinely flaky downstream dependency is itself a valid motivating reason to reach for any of these three, on top of the pure speed argument.
Implement a small Node.js mock server that returns a configurable response for GET /users/:id. Test code should be able to set the response payload and status for a given id at runtime through a control endpoint, and reset it between tests. Outline the code and explain how a test would use this server.
Sample Answer
Direct answer
Build a small Express server that keeps an in-memory map of configured per-id responses, exposes a control endpoint to set (and another to clear) that configuration at runtime, and serves whatever was last configured for a given id on GET /users/:id, returning a clear "not configured" response otherwise.
Structured elaboration
const express = require('express');
function createMockUserServer() {
const app = express();
app.use(express.json());
const responses = new Map();
app.get('/users/:id', (req, res) => {
const configured = responses.get(req.params.id);
if (!configured) {
return res.status(404).json({ error: 'no mock configured for this id' });
}
res.status(configured.status).json(configured.body);
});
app.post('/__mocks', (req, res) => {
const { id, status, body } = req.body;
if (!id || !status) {
return res.status(400).json({ error: 'id and status are required' });
}
responses.set(id, { status, body: body ?? {} });
res.status(201).json({ ok: true });
});
app.delete('/__mocks/:id', (req, res) => {
responses.delete(req.params.id);
res.status(204).end();
});
return app;
}
module.exports = { createMockUserServer };
Test code using supertest (executed):
const request = require('supertest');
const { createMockUserServer } = require('./mock-server');
function assertEqual(actual, expected, msg) {
if (actual !== expected) {
throw new Error(`FAIL: ${msg} (expected ${expected}, got ${actual})`);
}
}
async function main() {
const app = createMockUserServer();
let res = await request(app).get('/users/1');
assertEqual(res.status, 404, 'unconfigured id should 404');
res = await request(app).post('/__mocks').send({ id: '1', status: 200, body: { id: '1', name: 'Ada' } });
assertEqual(res.status, 201, 'configuring a mock should return 201');
res = await request(app).get('/users/1');
assertEqual(res.status, 200, 'configured id should return the configured status');
assertEqual(res.body.name, 'Ada', 'configured id should return the configured body');
await request(app).post('/__mocks').send({ id: '2', status: 503, body: { error: 'unavailable' } });
res = await request(app).get('/users/2');
assertEqual(res.status, 503, 'configured error status should be honored');
res = await request(app).delete('/__mocks/1');
assertEqual(res.status, 204, 'reset should return 204');
res = await request(app).get('/users/1');
assertEqual(res.status, 404, 'reset id should 404 again');
console.log('ALL 7 ASSERTIONS PASSED');
}
main().catch((e) => { console.error(e); process.exit(1); });
Executed with node: ALL 7 ASSERTIONS PASSED (the earlier draft referenced an assertEqual helper without defining it and never invoked main(), so it silently produced no output at all; this version defines the helper, calls main(), and the real count of assertions actually made is 7, not 5).
Worked example
A UI test that needs GET /users/42 to return a specific plan tier first calls POST /__mocks with { id: "42", status: 200, body: { id: "42", plan: "pro" } }, then exercises the page under test, then (in teardown) calls DELETE /__mocks/42 so the next test starts from a clean, unconfigured state rather than inheriting whatever the previous test left behind.
Trade-offs and pitfalls
The in-memory Map means all configured mocks are shared across whatever tests are running against this one server instance; if tests run in parallel against a single shared server process, they need distinct ids or their own server instance per test, otherwise one test's configured response can leak into another's assertions, the exact interference test isolation is meant to prevent. Explicitly resetting configured mocks between tests (or spinning up a fresh server per test) avoids this at the cost of some setup overhead per test.
Explain how dependency injection and programming to interfaces improve testability. Propose a small, language-agnostic pattern (constructor injection, a factory, or a service locator) you would ask engineers to adopt so that their code becomes easier to mock and supports reliable unit tests.
Sample Answer
Direct answer
Dependency injection means a class or function receives its collaborators from the outside (through a constructor parameter, a function argument, or a factory) instead of constructing or looking them up itself, and this is exactly what makes it possible to substitute a mock in tests without changing the code under test.
Structured elaboration
Three common patterns, in increasing order of flexibility:
- Constructor injection: the collaborator is passed into the constructor and stored as a field. Simplest, and makes the dependency explicit in the type signature, so it's visible at every call site.
- Factory injection: instead of injecting the collaborator directly, inject a factory function or object that can produce it, useful when the collaborator needs to be created fresh per call or configured based on runtime information not known at construction time.
- Service locator: the class asks a shared registry for its dependencies at the point of use, rather than receiving them explicitly. This is the least testable of the three, since a test now has to configure a global registry rather than simply passing in a test double, and the dependency is hidden from the type signature; prefer constructor or factory injection unless there's a specific reason (like deep call chains where threading every dependency through every layer is impractical) to fall back to a locator.
The reason this enables mocking at all: if a class reaches out and constructs its own PaymentGateway internally, a test has no seam to intercept that construction. If the PaymentGateway is instead passed in, a test can pass a mock implementing the same interface, and the class under test never needs to know or care that it's not talking to the real thing.
Worked example
A NotificationService that constructs its own SmtpClient internally (this.client = new SmtpClient(config)) cannot be unit tested without actually configuring SMTP or monkeypatching the class. Refactored to constructor injection, NotificationService(EmailClient client) accepts any object implementing the EmailClient interface; a test passes a mock EmailClient and asserts send() was called with the expected message, with zero real network activity and no SMTP configuration needed at all.
Trade-offs and pitfalls
Constructor injection can start to feel unwieldy when a class accumulates many dependencies through its constructor; that's usually a signal the class is doing too much and should be split, not a reason to reach for a service locator to hide the growing list. Programming to an interface (rather than a concrete class) is what actually enables substitution: injecting a concrete class with no interface still blocks mocking unless the mocking framework can subclass or bytecode-instrument concrete classes, which not all languages and tools support equally well.
You need to introduce automated tests into a large legacy monolith with minimal refactoring risk. Describe the practical seams and techniques you would create to make the code mockable and testable, and give a step-by-step plan to increase coverage while minimizing risk to the existing behavior.
Sample Answer
Direct answer
Introduce testability into legacy code by finding or creating a "seam", a point where you can substitute a different behavior without editing the class's own logic, typically by extracting an interface around a hard-coded dependency, wrapping a global or static call behind an injectable object, or using a language's interception features as a last resort.
Structured elaboration
A practical, risk-minimizing sequence:
- Add characterization tests first: before changing any production code, write tests that pin down the class's CURRENT behavior (even if that behavior isn't ideal), using whatever coarse-grained testing is possible today (an end-to-end call, or testing through the public API). These act as a safety net so later refactors can be verified not to change behavior.
- Find the smallest seam: look for a hard-coded
new SomeDependency()call, a static method call, or direct file/network access buried inside the class, and identify the minimal change that would let a test substitute something else there. - Extract an adapter/interface around that dependency: wrap the concrete class behind a small interface that mirrors only the methods actually used, then have the legacy class depend on the interface instead of the concrete implementation.
- Introduce a wrapper for globals or statics: if the dependency is a static method call or a global singleton, wrap it in a thin instance-based adapter class that a test can inject a fake implementation of, since most languages can't mock static/global calls directly without special tooling.
- Only reach for runtime interception (bytecode manipulation, monkeypatching) when extraction is genuinely impractical, since it works without changing production code but produces tests that are more fragile and harder for future readers to understand than an explicit seam.
- Grow real unit test coverage behind the new seam, then repeat the process on the next hard-coded dependency, expanding coverage incrementally rather than attempting one large rewrite.
Worked example
A legacy InvoiceGenerator class calls new PdfLibrary().render(invoice) directly inside a large generate() method. To make it testable: extract a PdfRenderer interface with a render(invoice) method, have the concrete PdfLibrary-backed implementation implement it, and change InvoiceGenerator to accept a PdfRenderer through its constructor instead of constructing PdfLibrary itself. Before making this change, a characterization test calls the existing generate() method with a few representative invoices and asserts on today's actual PDF output (or a hash of it), so if the refactor accidentally changes behavior, that test catches it immediately, even though it doesn't yet use any test doubles.
Trade-offs and pitfalls
The temptation on a large legacy class is to introduce many seams at once; doing that without characterization tests in place first risks silently changing behavior during the very refactor meant to make the class safer to change. Runtime interception techniques (monkeypatching a language runtime, bytecode-rewriting a "final" class) can unblock testing fast, but they tend to make the resulting tests fragile and harder to reason about than an explicit extracted seam, so treat them as a stopgap on the path to a real interface, not the end state.
Explain mocking versus stubbing versus service virtualization for integration tests. For each technique, describe when you would use it, its benefits and limitations in terms of fidelity versus control, how it affects long-term test maintenance, and how you would fit it into a CI workflow for a backend dependency.
Sample Answer
Direct answer
Mocking, stubbing, and service virtualization sit on a spectrum of increasing fidelity and decreasing control: a mock/stub replaces a dependency inside your process with a hand-written, minimal fake; service virtualization runs a separate, more realistic fake service (often generated from a real API contract) that your code talks to over the network exactly as it would talk to the real thing.
Structured elaboration
- Mocking/stubbing (in-process): you replace the dependency object itself. Fast, fully under your control, zero network involved. Best when the dependency's real network behavior (latency, serialization quirks, auth handshakes) isn't what the test cares about.
- Service virtualization (out-of-process): a separate process (or container) responds to real HTTP/gRPC calls with configured or recorded responses. Slower to start than an in-process mock, but exercises your real network/serialization code path, and can be shared across multiple test processes or even multiple teams.
- Deciding factor #1: what you're testing. If the unit under test's logic is what matters, mock. If you're testing that your HTTP client is wired correctly, that timeouts are handled, or that a whole flow works end-to-end without hitting a real third party, virtualize.
- Deciding factor #2: parallel execution. When many tests run in parallel, in-process mocks are cheap to instantiate one-per-test with no shared state to worry about; a single virtualized service instance can become a bottleneck or a source of cross-test interference unless it's stateless or given per-test isolation (a separate container, a request-scoped state key).
- Deciding factor #3: fidelity risk. A hand-written mock can silently drift from what the real dependency actually does. Service virtualization built from a real API contract (an OpenAPI spec, a recorded interaction) is less likely to drift, but only if that contract is kept current.
- Long-term maintenance. A hand-written mock is code you own forever: every time the real dependency's contract changes, someone has to remember to update the mock by hand, and nothing forces that to happen. A virtualized service generated from (or periodically checked against) a real contract shifts that burden toward keeping ONE shared contract current rather than every team's individual mock, which scales better as the number of consumers grows.
- Fitting into a CI workflow. In-process mocks need nothing extra: they live and die with the test process, so they parallelize for free across CI workers. Service virtualization needs an explicit CI step, starting the virtualized service (or a per-worker instance of it) before the test suite runs, tearing it down after, and giving each parallel CI worker its own instance or its own isolated state so two workers' tests don't interfere through a shared virtualized service.
Worked example
Testing a "look up shipping cost" feature that calls a shipping-rate API: for a fast unit test of the pricing math, mock the shipping client to return a fixed $4.99 and assert on the total. For an integration test verifying your retry-on-5xx logic actually retries, run a lightweight virtualized shipping service (for example a small local server serving canned responses) that you can configure per-test to return a 503 once and then a 200, so you exercise the real HTTP call and real retry code path. Mocking would hide a real bug here: if your retry logic accidentally retries on a 4xx instead of a 5xx, a mock that never returns a real HTTP status object at all wouldn't catch it, while the virtualized server, hit over real HTTP, would.
Trade-offs and pitfalls
Don't default to virtualization for everything: it adds process/container startup cost and a maintenance surface (keeping the virtual service's responses realistic). Don't default to mocking for everything either: a suite that only ever mocks the shipping client will never notice that your retry logic has a bug in exactly the code path a mock skips over. The safest posture is to use mocks for the majority of unit tests and reserve service virtualization for the specific integration tests whose job is to validate the boundary itself.
Unlock Full Question Bank
Get access to all 10 Mocking, Stubbing, and Test Isolation interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.