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.
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.
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.
In Java, using JUnit and Mockito, write a unit test for a Service.processOrder(order) method that is expected to charge a payment gateway, save the order via a repository, and publish an order-placed event. Verify the call order and the arguments passed to each collaborator, and add a test for the case where the payment call throws: the order must not be saved or published.
Sample Answer
Direct answer
Use Mockito to inject mocks for PaymentGateway, OrderRepository, and EventPublisher, then use ArgumentCaptor and InOrder to verify both the exact arguments passed to each collaborator and the order they were called in, plus a second test that verifies nothing is saved or published when the payment call throws.
Structured elaboration
import org.junit.Test;
import org.junit.Before;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
public class OrderServiceTest {
@Mock PaymentGateway paymentGateway;
@Mock OrderRepository orderRepository;
@Mock EventPublisher eventPublisher;
Service service;
@Before
public void setUp() {
MockitoAnnotations.openMocks(this);
service = new Service(paymentGateway, orderRepository, eventPublisher);
}
@Test
public void processOrder_chargesSavesAndPublishes_inOrder_withCorrectArguments() {
Order order = new Order("ord-1", 42.50);
service.processOrder(order);
verify(paymentGateway).charge(42.50);
ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class);
verify(orderRepository).save(orderCaptor.capture());
assertEquals("ord-1", orderCaptor.getValue().id);
ArgumentCaptor<OrderPlacedEvent> eventCaptor = ArgumentCaptor.forClass(OrderPlacedEvent.class);
verify(eventPublisher).publish(eventCaptor.capture());
assertEquals("ord-1", eventCaptor.getValue().orderId);
InOrder inOrder = inOrder(paymentGateway, orderRepository, eventPublisher);
inOrder.verify(paymentGateway).charge(42.50);
inOrder.verify(orderRepository).save(any(Order.class));
inOrder.verify(eventPublisher).publish(any(OrderPlacedEvent.class));
}
@Test
public void processOrder_whenPaymentThrows_orderIsNeitherSavedNorPublished() {
Order order = new Order("ord-2", 10.00);
doThrow(new PaymentDeclinedException("card declined"))
.when(paymentGateway).charge(10.00);
try {
service.processOrder(order);
fail("expected PaymentDeclinedException to propagate");
} catch (PaymentDeclinedException expected) { }
verify(orderRepository, never()).save(any(Order.class));
verify(eventPublisher, never()).publish(any(OrderPlacedEvent.class));
}
}
Compiled with javac and run with JUnit 4 + Mockito 5.14.2:
JUnit version 4.13.2
..
Time: 0.5s
OK (2 tests)
(On very new JDKs, Mockito's inline mock maker needs -Dnet.bytebuddy.experimental=true until Byte Buddy officially certifies that JDK version; this is a currency detail worth knowing rather than a code defect.)
Worked example
ArgumentCaptor proves not just that save was called, but that it was called with the SAME order object (matched here by its id) that was passed into processOrder, catching a bug where the code might accidentally construct or save a different order. InOrder proves the three calls happen in the sequence the business rule requires, charge, then save, then publish, catching a bug where, say, the event gets published before the payment is confirmed to have succeeded. The second test proves the negative case: on a thrown exception from the payment gateway, verify with never() that the downstream collaborators were never touched at all.
Trade-offs and pitfalls
Verifying call order with InOrder only where the order is actually a real business requirement avoids over-specifying the test: if charge/save/publish could legitimately happen in a different sequence without being a bug, asserting a specific order makes the test brittle for no real benefit. ArgumentCaptor compares whatever equality the captured type provides, if Order doesn't override equals, comparing captured fields directly (as done here with .id) is more reliable than asserting object identity or a default equals.
Unlock Full Question Bank
Get access to all 34 Mocking, Stubbing, and Test Isolation interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.