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.
Write a pytest unit test for a Python function schedule_next_run(last_run_timestamp) that returns the next UTC timestamp exactly 24 hours later. Use a time-freezing tool or monkeypatching to control system time so the test is deterministic, and include a test case that covers a daylight-saving-time boundary.
Sample Answer
Direct answer
Use freeze_time to pin the clock the function reads, so schedule_next_run is exercised at a known instant, then assert on the exact returned timestamp, including one test case that straddles a daylight-saving-time boundary.
Structured elaboration
The function under test operates purely on a UTC timestamp, so a correct implementation should be immune to local daylight-saving shifts entirely; the DST test case exists specifically to catch an incorrect implementation that accidentally reasons in local time or re-derives "now" from a local-time source partway through.
from datetime import datetime, timedelta, timezone
from freezegun import freeze_time
def schedule_next_run(last_run_timestamp: datetime) -> datetime:
"""Return the next run time exactly 24 hours after last_run_timestamp, in UTC."""
return last_run_timestamp + timedelta(hours=24)
@freeze_time("2026-06-15 10:00:00", tz_offset=0) # an ordinary day, nowhere near a DST transition
def test_schedule_next_run_normal_day():
now = datetime.now(timezone.utc)
result = schedule_next_run(now)
expected = datetime(2026, 6, 16, 10, 0, 0, tzinfo=timezone.utc)
assert result == expected
@freeze_time("2026-03-08 10:00:00", tz_offset=0)
def test_schedule_next_run_dst_boundary_us_spring_forward():
# US DST spring-forward happened 2026-03-08 (2am -> 3am local, US Eastern).
# Because schedule_next_run operates purely in UTC, the +24h arithmetic
# must NOT shift by the local wall-clock DST jump.
now = datetime.now(timezone.utc)
result = schedule_next_run(now)
expected = datetime(2026, 3, 9, 10, 0, 0, tzinfo=timezone.utc)
assert result == expected
assert (result - now) == timedelta(hours=24)
Executed with pytest:
py-sandbox/test_s9_schedule.py::test_schedule_next_run_normal_day PASSED
py-sandbox/test_s9_schedule.py::test_schedule_next_run_dst_boundary_us_spring_forward PASSED
2 passed in 0.08s
Worked example
The DST test freezes the clock exactly on the date of a real US spring-forward transition. If schedule_next_run were buggy and internally converted to local time, added "1 day" in local wall-clock terms, and converted back (a plausible mistake when a developer reaches for a naive local-time date + 1 day library call instead of UTC-timestamp arithmetic), the result would land 23 real hours later instead of 24, because one hour of local wall-clock time disappeared that day. The test's explicit (result - now) == timedelta(hours=24) assertion is what would catch that specific class of bug; the equality check on the expected UTC datetime alone would also catch it, but the explicit duration assertion makes the DST-specific intent of the test unambiguous to a future reader.
Trade-offs and pitfalls
freeze_time patches the standard library's time functions for the duration of the decorated test; if the code under test reads time through some other mechanism (a compiled extension, a separate process, a raw system call not covered by the patch), it won't be affected and the test will silently exercise the real clock instead. Always verify that a freeze-time test actually pins the value you expect by asserting on a specific timestamp, as done here, rather than only checking a relative delta, since a relative-only assertion can accidentally pass even if the freeze didn't take effect.
Give examples of using mocks or stubs to simulate exception and error conditions, such as a network timeout, a database constraint violation, or a message-broker disconnect. Explain why deliberately exercising these error paths in tests is valuable for product reliability, beyond just covering the happy path.
Sample Answer
Direct answer
Use mocks or stubs to make a dependency fail on demand, a network timeout, a database constraint violation, a message-broker disconnect, so error-handling code gets exercised deliberately in every test run, rather than only occasionally when something happens to actually break in a shared test environment.
Structured elaboration
- Network timeout: configure a stub HTTP client to raise a timeout exception instead of returning a response, and assert the calling code handles it (retries, falls back, or surfaces a clear error) rather than crashing or hanging.
- Database constraint violation: configure a stub or fake repository to raise a unique-constraint-violation exception on
save, and assert the calling code translates that into the right domain-level outcome (e.g., "this order already exists" rather than an unhandled 500). - Message-broker disconnect: configure a fake message publisher to raise a connection error, and assert the calling code either retries the publish, queues it for later, or at minimum doesn't silently lose the message without any indication.
Worked example
A checkout flow's happy-path test never encounters a database constraint violation naturally, since the fixture data is always fresh, so without deliberately injecting one, the "duplicate order" error-handling branch is completely untested. Configuring the repository stub to throw that specific exception on the second save call lets a test walk through exactly that branch and assert on the resulting user-facing error message, deterministically, on every run.
Trade-offs and pitfalls
The value here isn't just "does the error not crash the program", it's whether the SPECIFIC handling is correct: does a timeout trigger the intended retry policy, does a constraint violation map to the right domain error rather than a generic failure, does a broker disconnect actually get retried or surfaced rather than silently dropped. Deliberately exercising error paths is what catches the class of bug that only shows up in production during a real outage, when nobody wants to be discovering error-handling gaps for the first time.
Implement a deterministic test harness for asynchronous code that processes a queue of tasks in the background. Show how you would structure the tests to avoid flakiness by injecting or controlling the scheduler, and assert on the processing order of multiple queued tasks.
Sample Answer
Direct answer
Structure the production code to accept an injected scheduler (rather than calling setTimeout/setInterval or awaiting real timers directly), then in the test supply a fake scheduler that runs queued callbacks immediately and synchronously when flushed, so the whole queue drains deterministically with no real waiting and no flakiness from timing.
Structured elaboration
class FakeScheduler {
constructor() { this.pending = []; }
schedule(fn, delayMs) { this.pending.push({ fn, delayMs }); }
async flush() {
const jobs = this.pending;
this.pending = [];
for (const job of jobs) { await job.fn(); }
}
}
class QueueProcessor {
constructor(scheduler) {
this.scheduler = scheduler;
this.queue = [];
this.processedOrder = [];
}
enqueue(task) { this.queue.push(task); }
start() {
const step = async () => {
if (this.queue.length === 0) return;
const task = this.queue.shift();
await task();
this.processedOrder.push(task.name);
this.scheduler.schedule(step, 0);
};
this.scheduler.schedule(step, 0);
}
}
Test (executed with node):
function assertEqual(actual, expected, msg) {
if (actual !== expected) {
throw new Error(`FAIL: ${msg} (expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)})`);
}
}
const scheduler = new FakeScheduler();
const processor = new QueueProcessor(scheduler);
const order = [];
const makeTask = (name) => { const t = async () => { order.push(name); }; Object.defineProperty(t, 'name', { value: name }); return t; };
processor.enqueue(makeTask('task-A'));
processor.enqueue(makeTask('task-B'));
processor.enqueue(makeTask('task-C'));
processor.start();
let iterations = 0;
while (scheduler.pending.length > 0 && iterations < 100) {
await scheduler.flush();
iterations++;
}
assertEqual(order.join(','), 'task-A,task-B,task-C', 'tasks must process in first-in-first-out (FIFO) order');
console.log(`ALL ASSERTIONS PASSED, iterations = ${iterations}`);
Output: ALL ASSERTIONS PASSED, iterations = 4.
Production-code shape matters here: the queue processor is written to accept a scheduler object rather than calling the real timer APIs directly, which is the same dependency-injection style used to make time, randomness, and other side effects mockable in a test. This shape reads naturally whether the real scheduler ends up being Node's event loop, a browser's timer, or (in a mobile app) a coroutine dispatcher; the fake in the test just needs to implement the same schedule contract.
Worked example
Without the injected scheduler, this test would need real setTimeout calls and either sleep for a fixed wall-clock duration (slow, and still racy if the real processing takes longer than assumed) or poll for completion (flaky under CI load). With the fake scheduler, flush() runs every currently-queued callback to completion before returning, so draining the whole queue is just "call flush until nothing is pending", deterministic regardless of how fast or slow the actual machine running the test is.
Trade-offs and pitfalls
A fake scheduler that runs everything synchronously and instantly can hide real concurrency bugs that only manifest under genuine interleaving (two tasks actually racing against each other); it's excellent for testing processing order and business logic deterministically, but a separate test strategy built specifically to force real interleavings is needed to hunt for race conditions, since this fake's whole point is to remove concurrency, not exercise it.
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 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.
Unlock Full Question Bank
Get access to all 30 Mocking, Stubbing, and Test Isolation interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.