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.
Your CI pipeline is unstable because several tests depend on flaky or slow third-party services. Propose a hybrid strategy that decides, dependency by dependency, whether to mock it, virtualize it, or keep a small number of real integration tests. Give criteria for which tests should stay live, and explain how you would know if that mix is drifting the wrong way over time.
Sample Answer
Direct answer
Stabilize a flaky CI pipeline by deciding, dependency by dependency, whether to mock it, virtualize it, or keep a small number of real calls, rather than applying one blanket policy; the criteria are how flaky the dependency has actually been, how safety-critical it is, and how expensive it is to call for real.
Structured elaboration
A practical migration plan:
- Classify each external dependency by observed failure rate over the last few weeks of CI runs and by how central its real behavior is to what you're trying to validate.
- Mock-first for chronically flaky, low-risk dependencies: anything with a high enough failure rate that it's already causing "just re-run it" behavior, and where the business logic under test doesn't depend on the dependency's exact real-world quirks.
- Keep or add a small number of real integration tests for high-risk dependencies: run them less frequently (nightly, or gated on a separate slower pipeline) rather than on every commit, so their occasional real flakiness doesn't block every PR.
- Migrate incrementally, dependency by dependency, verifying after each migration that the pipeline's overall flakiness rate actually drops and that no regression slipped through because a mock is now hiding real behavior.
- Watch for the mix drifting the wrong way: track what fraction of tests exercise a real dependency over time; if it trends toward zero, the suite is losing its ability to catch real integration bugs, and if a chronically-mocked dependency's real API changes, nothing will tell you until it breaks in production.
Worked example
A pipeline has three external dependencies: a currency-conversion API (occasional slowness, low business risk, used in many tests), a fraud-check API (occasional slowness, high business risk), and an email-sending service (occasional slowness, low risk, used in far fewer tests). The right migration: mock currency conversion everywhere except in one or two integration tests that revalidate the response shape weekly; keep the fraud-check API real in a small, nightly-run suite specifically because its exact decision logic is what the business needs the tests to protect; fully mock the email service, since almost no test needs to verify that email actually sends, only that the code attempted to send it.
Trade-offs and pitfalls
The plan fails if it's applied as an all-or-nothing switch: flipping every test to mocks in one pass removes the flakiness immediately but can silently remove real-integration coverage for months before anyone notices a drift. Track the migration with a simple metric, like "count of tests exercising each real dependency," reviewed periodically, so the team can see the mix drifting and course-correct before it becomes invisible.
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 test payment flows that must validate idempotency and retry behavior, but you cannot call the production payment gateway from automated tests. Propose a strategy to mock or virtualize the gateway that preserves realistic behavior, including stateful idempotency tokens, duplicate-request detection, and injected errors. How would you verify that your mock is actually correct?
Sample Answer
Direct answer
Mock or virtualize the payment gateway with a stateful fake that tracks idempotency tokens and duplicate requests the same way the real gateway would, inject configurable error responses to exercise failure handling, and verify the mock's correctness by periodically checking its behavior against the real gateway's documented (or sandboxed) semantics rather than trusting it was built right once and never revisited.
Structured elaboration
- Stateful idempotency tokens: the fake gateway must remember which idempotency keys it has already seen, and return the SAME response for a repeated key rather than processing the charge twice, exactly mirroring how a real payment provider's idempotency guarantee works. A stateless fake that just always "succeeds" cannot exercise this at all.
- Duplicate-request detection: beyond idempotency keys, the fake should be able to detect and reject a genuinely duplicate charge attempt (same amount, same customer, in a short window) if that's part of what your production code is meant to guard against, so tests can verify your code's OWN duplicate-detection logic and not just the gateway's.
- Injected errors: the fake needs configurable failure modes (a decline, a timeout, a rate-limit response) that tests can select per-scenario, so retry logic, user-facing error handling, and reconciliation logic all get real test coverage.
- Verifying the mock is correct: this is the hardest and most often-skipped part. Options include periodically running the same test suite against the real gateway's sandbox environment and diffing behavior, keeping the fake's logic reviewed against the provider's published API documentation whenever it changes, or building the fake from the provider's official sandbox responses (a form of contract-based generation) rather than from memory of how it's supposed to work.
Worked example
A FakePaymentGateway stores a dictionary of idempotency_key -> response and, when charge(amount, idempotency_key) is called with a key already in the dictionary, returns the stored response unchanged instead of creating a new charge. A test configures the fake to return a "declined" response for a specific key, calls the order-processing code, and asserts the order is marked as payment_failed and never marked paid. A second test calls charge twice with the SAME idempotency key and amount, and asserts only one charge was recorded internally by the fake, proving the order code (or the fake itself, whichever owns the idempotency contract in this design) doesn't double-charge on a retried request.
Trade-offs and pitfalls
A fake payment gateway that only ever returns success teaches the team nothing about how the system behaves under decline, timeout, or duplicate-request conditions, exactly the conditions that matter most for a payment flow's correctness and are hardest to safely reproduce against a real gateway. The single biggest risk with any hand-built fake of a payment provider is confidence without verification, a fake that has silently drifted from the real gateway's actual idempotency window or error-response shape can make a whole suite pass while a real regression ships, so revisiting the fake against the provider's real documented behavior on a schedule, not just at initial build time, is part of the design.
You are writing integration tests for a service that depends on a rate-limited third-party API. Propose a checklist of practices to keep the tests stable and safe to run in CI without exceeding the provider's rate limits or causing real side effects. Explain which parts of your checklist rely on mocking versus other techniques, and how you would categorize existing tests by how much they depend on that external dependency.
Sample Answer
Direct answer
Keep integration tests stable against a rate-limited third-party API by combining mocking for the bulk of tests, a small number of real calls that respect the provider's limits, circuit breakers and retries in the code under test itself, and a way to categorize tests by how much they actually depend on the external service, rather than treating "call the real API" and "mock it" as the only two options.
Structured elaboration
A practical checklist:
- Mock the dependency for the majority of tests: any test whose purpose is your own business logic, not the third party's real behavior, should not touch the real API at all.
- Throttle deliberately for the small number of tests that do call the real API: add explicit rate limiting or spacing in the test suite itself (not just relying on the provider's own limit response) so your CI run never approaches the provider's ceiling and never impacts their real operations.
- Use stubbing and replay for realistic failure scenarios: a controllable stub or a recorded-and-replayed real response lets you exercise rate-limit (429) or downtime (5xx/timeout) responses on demand, without needing the real provider to actually be rate-limiting or down at test time.
- Test your resilience mechanisms directly: circuit breakers, retries, and feature flags that gate a feature off if the dependency is unhealthy should have their own tests, using the stub to simulate the exact failure conditions those mechanisms exist to handle.
- Categorize tests by dependency: tag or group tests by which external dependency they touch and how (mocked-only versus real-call), so a provider outage or rate-limit change only threatens a known, small, clearly-labeled set of tests, and the team can quickly decide whether to skip that group temporarily without guessing which tests are affected.
Worked example
A weather-data integration has 40 tests total. 35 are mocked entirely and run on every commit. 5 are tagged @real_api and run only in a nightly job, spaced 2 seconds apart to stay well under the provider's per-minute rate limit and specifically chosen to validate things a mock cannot: that the real response schema still matches what the code expects, and that a real 429 response is handled by the circuit breaker exactly as designed. A separate stub-based test simulates a 429 explicitly to verify the circuit breaker opens after 3 consecutive failures, without needing the real provider to actually be rate-limiting at that moment.
Trade-offs and pitfalls
Treating rate-limit or intermittent third-party slowness purely as a "make it more reliable" problem and mocking everything removes the flakiness but also removes the team's only signal that the real integration still works; the categorization step (which tests actually call the real dependency) is what prevents that risk from becoming invisible. A checklist that never mentions respecting the provider's actual limits, only the test suite's own convenience, risks getting your test account rate-limited or banned, which is a self-inflicted outage of your own CI.
Mocks are only useful for as long as they match how the real dependency actually behaves. Design an automated process to detect when a mock's expectations have drifted from production behavior, using telemetry, API logs, or sampled traffic. Cover what data you collect, how you compare it to the mock, and how you keep false-positive alerts low.
Sample Answer
Direct answer
Two complementary strategies keep mocks honest over time: an OBSERVATIONAL approach that compares mock expectations against real production traffic or telemetry after the fact, and a CONTRACTUAL approach that generates or verifies mocks directly against a schema or a consumer-driven contract before drift can even happen.
Structured elaboration
Observational (telemetry-based drift detection):
- Data collected: sampled real request/response pairs from production (or a realistic staging environment), API access logs, or existing observability traces for the real dependency.
- Comparison: diff the real observed shape (fields present, types, value ranges) against what the mock's configured responses assume, field by field, flagging any new field the mock doesn't know about, any field the mock assumes but the real service no longer sends, or a type/format mismatch.
- False-positive control: tolerate genuinely optional or rarely-populated fields rather than flagging every absence, and use a reasonable sampling window (not a single request) before concluding a field has actually disappeared, since a single missing optional field in one sample isn't the same as a real contract change.
Contractual (schema-generated, CI-gated):
- Consumer-driven contract testing: the consuming team encodes exactly what fields and shapes they depend on as an explicit contract; the providing team's CI runs that contract against their real service and fails the build if a breaking change would violate it, catching drift before it ever reaches a deployed mock.
- Generating mocks from a schema: rather than hand-writing a mock's response shape, generate it from the same OpenAPI/schema definition the real service is built from (or verified against), so the mock and the real service structurally cannot diverge as long as the schema itself stays current.
- CI gate verification: wire contract or schema verification into the pipeline so a breaking provider-side change is caught automatically, rather than relying on someone remembering to update mocks by hand.
Worked example
A billing-service mock has drifted: the real service renamed a totalCents field to totalAmountCents months ago, but nobody updated the mock, and every mocked test still passes because the mock never talks to the real service at all. A telemetry-based drift detector sampling real billing responses would flag that the mock's assumed field name no longer appears in real traffic. Separately, if the consuming team had a consumer-driven contract asserting the exact field name it depends on, the providing team's CI would have failed the rename outright, before it ever shipped, catching the problem at the source rather than downstream in a stale mock.
Trade-offs and pitfalls
The observational approach is easier to bolt onto an existing system (it only needs read access to traffic or logs) but is inherently reactive, it tells you drift has ALREADY happened, sometime after the fact. The contractual approach is more preventive but requires buy-in and tooling from both the consuming and providing teams, and only protects against drift for the specific fields the contract actually encodes; presenting both as complementary, rather than picking one as "the" answer, reflects that neither alone covers every failure mode.
Unlock Full Question Bank
Get access to all 6 Mocking, Stubbing, and Test Isolation interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.