Distributed Systems and Microservices Testing Questions
Testing systems composed of many interacting services. Covers integration and end-to-end testing across service boundaries, handling eventual consistency and partial failure, and validating behavior in distributed, specialized architectures. Includes fault injection and testing at scale.
Design tests for a real-time collaborative editor that must keep multiple clients in sync with low latency. Cover out-of-order messages, partial message loss, reconnection with missed operations, and consistency under concurrent edits, and explain how you would test both an operational-transformation and a CRDT-based approach end to end.
Sample Answer
Direct answer
Drive the client-sync tests with a scriptable network-simulation layer that can reorder, drop, and delay operations deterministically between simulated clients, assert that every client eventually converges to the same document state regardless of the operation order it happened to see, and test the OT and CRDT approaches with the same scenario generator so their behavior under identical fault conditions is directly comparable.
Structured elaboration
- Deterministic multi-client simulation. Model each collaborating client as a process with its own local document state and an outbound/inbound message queue that a test-controlled network layer can reorder, drop, or delay. Drive scenarios from a seeded generator so a found divergence bug is exactly reproducible.
- Out-of-order and partial-loss scenarios. Generate concurrent edits from two or more clients, deliver them to a third client in a scrambled order, and separately simulate a client that misses some intermediate operations entirely (a dropped message) before reconnecting.
- Reconnection with missed operations. After a simulated disconnect, replay the operations the client missed (via whatever recovery mechanism the system uses: an operation log replay for OT, a state-based or delta-based sync for CRDTs) and assert the reconnected client converges to the same state as clients that were never disconnected.
- Convergence as the core invariant, for BOTH approaches. Regardless of whether the system uses operational transformation (which must correctly transform concurrent operations against each other in whatever order they're applied) or a CRDT (which is designed to converge by construction, given the right merge function), the test suite's central assertion is the same: after all in-flight operations settle, every client's document state must be identical. Test this by comparing every client's final state pairwise, not just checking each one individually against an expected value, since the expected value under concurrent edits from multiple clients is often "whatever a correct algorithm converges to," not a single hand-computed answer.
- Consistency under concurrent edits, specifically. Have two clients edit the SAME region of the document concurrently (not just different regions, which is the easy case) and assert the result is a defined, deterministic merge (for OT: consistent with the transformation function's intent-preservation rules; for CRDT: consistent with the data type's defined merge semantics), not simply "didn't crash."
Worked example
A convergence check applied after a scripted, lossy, reordered delivery scenario:
def test_all_clients_converge_after_reordered_and_lossy_delivery(seed=99):
network = ScriptableNetwork(seed=seed, reorder=True, drop_rate=0.2)
clients = [CollabClient(f"c{i}", network) for i in range(3)]
clients[0].insert(pos=0, text="Hello")
clients[1].insert(pos=5, text=" World")
clients[2].delete(pos=0, length=1) # concurrent edit touching the same region
network.deliver_all_pending(max_rounds=10) # applies reordering/drops per the seed
network.reconnect_and_replay_missed(clients[2]) # client 2 missed some ops due to the drop_rate
final_states = {c.name: c.document_text() for c in clients}
unique_states = set(final_states.values())
assert len(unique_states) == 1, f"clients diverged after settling (seed={seed}): {final_states}"
Running this across a sweep of seeds, with both an OT-backed and a CRDT-backed CollabClient implementation behind the same test, directly compares how each approach handles the identical fault script.
Trade-offs and pitfalls
- OT's correctness depends entirely on the transform function handling every pairwise combination of concurrent operation types correctly; a gap in that function (a case nobody wrote a transform rule for) is exactly the kind of bug that only shows up under a reordering/loss scenario like the one above, never in a simple sequential test.
- CRDTs are convergent by construction for well-formed operations, so tests should focus less on "does it converge" (which a correct CRDT design guarantees) and more on "does it converge to something the USER actually intended" (a mathematically valid merge can still be a confusing outcome from the user's perspective, especially for concurrent edits to the same text region).
- A scriptable network simulator needs to model realistic loss/reorder patterns, not just an on/off switch; testing only "never any loss" and "complete random chaos" misses the more common real-world middle ground (occasional reordering, rare drops), which is where the more subtle convergence bugs tend to live.
Design strategies to detect and prevent cascading failures caused by a flaky downstream service, verified through your integration tests and staging environment. Include service virtualization, latency and error injection, and how you'd test that circuit breakers and timeouts actually engage, and how you'd make a test failure actionable for developers.
Sample Answer
Direct answer
Strategy is layered: use service virtualization to make a downstream's flakiness controllable and repeatable in tests, inject latency and errors at that virtualized boundary to exercise the calling service's resilience logic directly, assert the resilience mechanisms (timeouts firing, circuit breakers opening) actually engage rather than merely "the call eventually returned something," and make every test failure carry enough context (which mechanism should have engaged and didn't) that a developer can act on it immediately.
Structured elaboration
- Service virtualization as the control point. Replace the real downstream with a virtualized stand-in the test fully controls, so "the downstream is flaky" becomes a deterministic, repeatable test input (configure the virtualized service to return errors, hang, or respond slowly on command) rather than something you can only hope to reproduce against a real, genuinely-unreliable dependency.
- Injecting latency and errors precisely. Configure the virtualized dependency to simulate the SPECIFIC failure shape you're testing for: a slow response just under the caller's timeout (does the caller still time out appropriately, or does it wait forever due to a misconfigured client timeout), a burst of errors (does the caller's circuit breaker actually open after the configured error threshold), and a full hang (does the caller's own timeout fire rather than blocking a thread/connection indefinitely).
- Asserting the mechanism engaged, not just the outcome. It is not enough to assert "the caller returned an error instead of crashing"; assert the SPECIFIC mechanism did its job: the circuit breaker's state actually transitioned to open, a fallback path was actually invoked (not just that some response came back), and the caller stopped issuing new outbound calls to the failing dependency once the breaker opened (proving the breaker is actually protecting the failing dependency from further load, which is the entire point of a circuit breaker).
- Making failures actionable. When a resilience-pattern test fails, its assertion message should name exactly what should have happened ("circuit breaker should have opened after 5 consecutive failures, but remained closed after 8") rather than a bare
assert result == expected, since these tests exist specifically to catch subtle misconfigurations (a threshold set wrong, a timeout not actually wired to the client) that are otherwise invisible until a real incident.
Worked example
def test_circuit_breaker_opens_and_stops_calling_failing_dependency():
virtual_downstream = VirtualizedService(name="inventory-service")
virtual_downstream.configure_all_calls_fail()
client = ResilientClient(virtual_downstream, breaker_threshold=5, breaker_cooldown_s=1.0)
for _ in range(5):
try:
client.check_inventory("sku-1")
except DependencyError:
pass
assert client.breaker_state == "OPEN", (
f"expected circuit breaker OPEN after 5 consecutive failures, got {client.breaker_state!r}"
)
calls_before = virtual_downstream.call_count
result = client.check_inventory("sku-2") # breaker is open: should short-circuit
assert virtual_downstream.call_count == calls_before, (
"a call while the breaker is OPEN must not reach the failing dependency at all"
)
assert result.source == "fallback", f"expected a fallback response while open, got {result.source!r}"
Trade-offs and pitfalls
- Testing against a virtualized dependency proves the CALLER's resilience logic works given a controlled failure shape; it does not prove the real downstream fails in exactly that shape in production, so pair this with production observability (actual error rates, actual breaker-state transitions) to confirm the assumptions the tests encode still hold.
- The most common false pass in this area is asserting only the final returned value and never inspecting internal breaker/circuit state; a system that happens to return a correct-looking fallback response even with a broken (always-closed) circuit breaker will pass a shallow test while still hammering the failing dependency with load in production.
- Latency-injection tests are sensitive to the ACTUAL configured client timeout; if the test's injected delay is close to the timeout threshold, minor scheduling jitter in CI can make the test flaky in either direction. Inject a delay clearly and safely on one side of the threshold (well above or well below), not right at the boundary, unless you are specifically testing boundary behavior with a tolerant, retried assertion.
Create a test plan to detect deadlocks and livelocks in a microservices architecture that uses distributed locks and RPC calls. Describe adversarial scheduling and fault-injection techniques you'd use to provoke the condition, resource-starvation tests, and watchdogs that assert forward progress. Explain which observability signals and assertions you'd add to automated tests to detect these conditions.
Sample Answer
Direct answer
Combine adversarial scheduling (deliberately interleaving lock-acquisition attempts and RPC calls to provoke a cycle) with resource-starvation tests (holding a lock or exhausting a pool just long enough to force contention) and a watchdog that asserts forward progress within a bounded time, so the test suite catches both a hard deadlock (nothing ever progresses) and a livelock (things keep happening, but nothing useful completes).
Structured elaboration
- Provoking the condition deliberately. A deadlock or livelock is, by definition, rare under normal random timing; a test suite that just runs the system under light concurrent load will usually not hit it. Instead, script the specific interleaving that creates a lock-ordering cycle (service A acquires lock 1 then requests lock 2 from service B, while service B acquires lock 2 then requests lock 1 from service A) using controllable delays or an explicit test-only coordination hook that pauses each side at the critical moment.
- Resource-starvation tests. Separately, saturate a shared resource (a connection pool, a thread pool, a rate-limited downstream) to just below its limit and add one more contender, and assert the system either queues it fairly and eventually serves it (progress, just delayed) or rejects it explicitly (a defined backpressure signal), rather than silently hanging forever.
- Watchdog assertion. Wrap the scenario in a watchdog that asserts SOME forward-progress signal (a completed request, an incrementing counter, a released lock) occurs within a generous but bounded time window; a livelock is specifically the case where activity (retries, lock attempts) continues but the progress signal never fires, so "no timeout occurred" is not sufficient evidence of correctness, only "the progress signal fired" is.
- Observability signals to add. Thread/goroutine dumps or stack traces on timeout (to see exactly where each participant is blocked), lock-acquisition and lock-wait-time metrics (a lock held far longer than its expected duration is a strong deadlock signal even before a full hang), and request-latency histograms (a livelock often shows as a spike in retries or a plateau in the completion rate, not literally zero throughput).
Worked example: a hard deadlock
A scripted two-service lock-ordering scenario with a watchdog assertion:
import threading
def test_lock_ordering_cycle_is_prevented_or_detected():
lock1, lock2 = threading.Lock(), threading.Lock()
progress = threading.Event()
barrier = threading.Barrier(2)
def service_a():
with lock1:
barrier.wait() # deliberately synchronize both sides at the critical moment
with lock2:
progress.set()
def service_b():
with lock2:
barrier.wait()
with lock1:
progress.set()
# daemon=True is deliberate: this scenario deadlocks the two threads
# PERMANENTLY (Python's plain threading.Lock has no built-in deadlock
# detector, unlike a real database engine). Without daemon=True, the two
# blocked threads never terminate, and the interpreter hangs forever at
# exit waiting to join them, even after the watchdog assertion below has
# already correctly failed and reported the deadlock.
t1 = threading.Thread(target=service_a, daemon=True)
t2 = threading.Thread(target=service_b, daemon=True)
t1.start(); t2.start()
made_progress = progress.wait(timeout=2.0) # the watchdog
assert made_progress, (
"deadlock detected: neither side made progress within the watchdog timeout "
"(this specific interleaving requires a consistent lock-ordering discipline, "
"or a lock-timeout-and-retry strategy, to resolve)"
)
t1.join(timeout=0.1); t2.join(timeout=0.1)
This test is EXPECTED to fail against a naive implementation that acquires locks in inconsistent order (that is the point: it deliberately provokes the classic lock-ordering deadlock), and passing means the system under test has a real mitigation (consistent lock ordering, a timeout-and-retry, or a deadlock detector) in place.
Worked example: a livelock, the other half of this question
A hard deadlock and a livelock look identical from a bare "did it time out" check, so a suite that only has the example above has not actually tested for livelocks at all. Here two "polite" agents each back off the instant they detect contention, in perfect lockstep, so neither ever completes, yet both stay busy retrying: a deterministic, single-threaded simulation (rather than real OS threads, to avoid exactly the kind of real-timing race that makes ad hoc threaded livelock demos flaky) makes this reliably reproducible:
class Agent:
def __init__(self, name, own_lock, other_lock):
self.name = name
self.own_lock = own_lock
self.other_lock = other_lock
self.holds_own = False
self.retries = 0
self.done = False
def decide(self, snapshot):
"""Decide this round's action from a snapshot taken at the START of
the round, so neither agent gets a sequencing advantage within the
round -- a true simultaneous update, not a sequential one."""
if self.done:
return "noop"
if not self.holds_own:
return "take_own"
if snapshot.get(self.other_lock) is None:
return "take_other"
return "back_off"
def apply(self, action, held_by):
if action == "take_own":
held_by[self.own_lock] = self.name
self.holds_own = True
elif action == "take_other":
held_by[self.other_lock] = self.name
self.done = True
elif action == "back_off":
held_by[self.own_lock] = None
self.holds_own = False
self.retries += 1
def test_livelock_symmetric_polite_backoff_never_converges(max_steps=200):
held_by = {"lock1": None, "lock2": None}
a = Agent("a", "lock1", "lock2")
b = Agent("b", "lock2", "lock1")
for step in range(max_steps):
if a.done or b.done:
break
snapshot = dict(held_by)
action_a, action_b = a.decide(snapshot), b.decide(snapshot)
a.apply(action_a, held_by)
b.apply(action_b, held_by) # both actions land together, from the same snapshot
made_progress = a.done or b.done
assert not made_progress, "expected the symmetric polite back-off pattern to livelock forever, not complete"
assert a.retries > 10 and b.retries > 10, (
f"expected substantial, ongoing retry activity from both sides (the defining signature of a "
f"livelock, as opposed to a deadlock's total silence): got a={a.retries} b={b.retries}"
)
Running this prints made_progress=False with a.retries=100 b.retries=100 after the full 200 simulated rounds: real, ongoing activity, and zero useful progress, which is exactly the property a watchdog based only on "did anything happen" would miss, and exactly why the observability signals in point 4 (a retry-rate or lock-attempt counter, not just a completion counter) matter for distinguishing this from a slow-but-healthy system.
Trade-offs and pitfalls
- A watchdog timeout that is too short will falsely flag a system that is merely slow (heavily contended but still making progress) as deadlocked; calibrate the timeout against the system's actual expected worst-case latency under contention, not an arbitrary round number.
- Deliberately scripted interleavings (using a barrier, as above) prove a SPECIFIC scenario is or isn't handled; they do not prove the absence of ALL possible deadlocks, which is a fundamentally harder problem. Pair scripted scenarios with production-side lock-wait-time monitoring as a complementary, ongoing detection mechanism.
- Distinguishing a livelock from legitimate-but-slow retry behavior requires a real progress signal, not just "activity is happening"; a system that keeps retrying a doomed operation forever looks identical to one that is slowly succeeding unless the test asserts on an actual completion signal, not just CPU or network activity.
- When a scenario is EXPECTED to deadlock or livelock permanently (as both examples above are, by design), make sure any threads used to model it are daemonized or otherwise bounded; a non-daemon thread that blocks forever will hang the test process itself at exit, turning a correctly-failing assertion into a CI job that never returns.
Design an integration-test harness for a data pipeline that validates schema, transformations, and invariants at scale. Explain how you'd generate synthetic large datasets with corner-case records such as nulls, late-arriving records, out-of-order events, and extreme values, how you'd run these tests locally and in CI without processing petabytes, and what assertions you would use to detect silent data corruption or semantic drift.
Sample Answer
Direct answer
Generate synthetic datasets that deliberately include the corner cases (nulls, late-arriving records, out-of-order events, extreme values) at a small enough scale to run locally and in CI, define explicit invariants the pipeline must preserve (row counts reconciling across stages, no unexpected nulls in non-nullable fields, referential relationships holding), and assert those invariants at each pipeline stage rather than only on the final output, so a stage that silently corrupts or drops data is caught at the stage it happens, not several transformations later.
Structured elaboration
- Synthetic data with deliberate corner cases. Generate a small (thousands, not billions, of rows) but representative synthetic dataset with explicit corner cases baked in by construction: some records with null values in optional fields, some records timestamped as arriving "late" relative to their logical event time, some events delivered out of order relative to their timestamps, and some fields at extreme (very large, very small, boundary) values. Because the dataset is synthetic and small, the test suite can run locally and in CI in seconds, never processing anything close to the real production data volume.
- Explicit, checkable invariants. Define invariants the pipeline should preserve regardless of the specific transformation logic: total row count in equals total row count out (accounting for any intentional filtering, which should itself be a counted, asserted quantity, not silent shrinkage), no unexpected nulls appear in fields the schema declares non-nullable, and any join or aggregation preserves a known referential relationship (every foreign key in the output actually exists in the corresponding dimension).
- Per-stage assertions, not only end-to-end. Assert these invariants after EACH transformation stage, not only on the final output; a pipeline with five sequential transformations that only checks the final result cannot tell you which of the five stages introduced a silent corruption, while checking after each stage narrows a failure to the exact transformation responsible.
- Detecting semantic drift. Beyond structural invariants (counts, nulls, referential integrity), include a small number of hand-computed "golden" expected outputs for specific, deliberately-chosen input rows (including the corner cases), and assert the pipeline's actual output for those specific rows matches the hand-computed expectation exactly; this catches SEMANTIC bugs (a transformation subtly computing the wrong value) that pure structural invariants like row counts would miss entirely.
Worked example
import pandas as pd
def make_synthetic_dataset():
return pd.DataFrame([
{"event_id": "e1", "user_id": "u1", "amount": 100, "event_time": "2026-01-01T10:00:00", "ingested_time": "2026-01-01T10:00:01"},
{"event_id": "e2", "user_id": "u1", "amount": None, "event_time": "2026-01-01T10:01:00", "ingested_time": "2026-01-01T10:01:01"}, # null amount
{"event_id": "e3", "user_id": "u2", "amount": 50, "event_time": "2026-01-01T09:00:00", "ingested_time": "2026-01-01T10:05:00"}, # late-arriving (ingested hours after its event_time)
{"event_id": "e4", "user_id": "u2", "amount": 10**9, "event_time": "2026-01-01T10:02:00", "ingested_time": "2026-01-01T10:02:01"}, # extreme value
])
def test_pipeline_preserves_row_count_and_flags_nulls_explicitly():
raw = make_synthetic_dataset()
transformed = pipeline.transform(raw)
assert len(transformed) == len(raw), "transformation must not silently drop rows"
null_amount_flags = transformed[transformed["event_id"] == "e2"]["amount_is_estimated"]
assert null_amount_flags.iloc[0] == True, (
"a null amount must be explicitly flagged as estimated/imputed, not silently defaulted to 0 "
"or dropped without a trace"
)
def test_late_arriving_record_assigned_to_correct_event_window():
raw = make_synthetic_dataset()
windowed = pipeline.assign_time_window(raw)
late_record = windowed[windowed["event_id"] == "e3"].iloc[0]
assert late_record["window"] == "2026-01-01T09:00", (
"a late-arriving record must be windowed by its EVENT time, not its ingestion time, "
"or downstream aggregates for the correct hour will be silently wrong"
)
Trade-offs and pitfalls
- Synthetic data at a small scale can miss a bug that only manifests at real production volume (a performance-driven bug like an out-of-memory condition, or a rare data pattern too infrequent to appear in a small synthetic sample); pair this suite with periodic, carefully-scoped validation against a genuinely representative (and privacy-safe) sample of real data, rather than relying on synthetic data alone forever.
- Row-count and null-check invariants are cheap and catch a wide class of bugs, but they cannot catch a transformation that is subtly semantically wrong while preserving counts and non-null-ness; the hand-computed golden-output checks are what catch that class, and are worth the extra authoring effort for at least the pipeline's most business-critical transformations.
- Testing per-stage rather than only end-to-end requires the pipeline to expose intermediate outputs in a testable way; if the pipeline is a single opaque black box, invest in adding that intermediate visibility, since it pays for itself the first time a silent mid-pipeline corruption would otherwise have taken hours to isolate.
Describe strategies for testing eventual consistency in a distributed system. Give concrete techniques for asserting eventual state and detecting how wide the consistency window is, without writing tests that assume strong consistency. Cover both a synchronous HTTP-based interaction and a message-driven workflow.
Sample Answer
Direct answer
Test for eventual consistency by asserting on the eventual state within a bounded window, never on immediate state. Concretely: poll with a timeout and backoff until the expected state appears (or the timeout fails the test), or better, have the write path return a token (a version, timestamp, or offset) and have the read assert that a later read reflects that specific token rather than asserting an exact wall-clock delay.
Structured elaboration
There are two families of technique, and which one applies depends on how the client observes the system:
-
Synchronous HTTP-based interaction (a client calls a write endpoint, then later calls a read endpoint):
- Poll-until-true with a hard timeout. Never
sleep(N)and assert once; that is either flaky (N too small) or slow (N too large). Poll on an interval, assert against a maximum wait, and fail loudly with the last-seen state if the timeout elapses. - Causality token / read-your-writes token. Have the write response return an opaque version (an ETag, a Kafka-style offset, a database LSN, or a simple monotonic counter). The read call then either passes that token to the read API (if the system supports read-your-writes) or the test keeps polling until the token appears in the read response, which converts a fuzzy "is it there yet" into a precise "does the response reflect at least version V" check.
- Poll-until-true with a hard timeout. Never
-
Message-driven workflow (a write triggers async processing across services):
- Consumer-side checkpoint. Have each consuming service write a durable marker (a row, a counter increment, a trace span) when it finishes processing. The test polls that marker rather than guessing a downstream side effect. This avoids depending on internal implementation details of the downstream service.
- Probe topic / shadow consumer. Where you cannot instrument the production consumer, attach a separate test-only consumer to the same topic that increments a counter per message it sees; that consumer's progress correlates with the production consumer's progress without touching production code paths.
In both families, the key discipline is: assert eventual PROPERTIES, not the exact number of milliseconds it took. "Contains this item" or "reflects at least version N" are properties. "Fewer than 3 seconds" is a flaky proxy for a property, and should only appear as a generous safety-net timeout, never as the assertion itself.
Worked example
A minimal, generic poll-until-consistent helper (works for either family; the get_state closure is what differs):
import time
def assert_eventually(get_state, predicate, timeout_s=5.0, interval_s=0.1):
deadline = time.monotonic() + timeout_s
last_seen = None
while time.monotonic() < deadline:
last_seen = get_state()
if predicate(last_seen):
return last_seen
time.sleep(interval_s)
raise AssertionError(
f"consistency window exceeded {timeout_s}s; last observed state: {last_seen!r}"
)
# HTTP example: write returns a version, read must reflect >= that version
written_version = write_client.create_order(order) # e.g. returns 42
final = assert_eventually(
get_state=lambda: read_client.get_order_version(order.id),
predicate=lambda v: v is not None and v >= written_version,
)
assert final >= written_version
# Message-driven example: poll a consumer-owned checkpoint row instead of a
# side effect you don't control
assert_eventually(
get_state=lambda: checkpoint_store.get("order-indexer", order.id),
predicate=lambda checkpoint: checkpoint is not None,
)
The failure message on timeout deliberately includes last_seen; a bare AssertionError with no context is the single most common reason eventual-consistency tests are slow to debug when they do legitimately fail.
Trade-offs and pitfalls
- A timeout that is too tight makes the test flaky under real system load (CI runners are frequently slower and noisier than a laptop); a timeout that is too generous makes a genuine regression (something that never converges) take minutes to fail instead of seconds. Pick the timeout from measured p99 propagation latency in a realistic environment, not a guess.
- Testing "it eventually converges" is necessary but not sufficient: also test the window itself is bounded under load, not just under a quiet system, otherwise a regression that only shows up under concurrent writes ships unnoticed.
- Never assert exact ordering of independent eventual updates unless the system actually guarantees an order; asserting
state == exact_expected_listwhen the system only guarantees eventual membership is the single most common cause of a falsely "correct" test that starts flaking the moment traffic increases.
Unlock Full Question Bank
Get access to all 45 Distributed Systems and Microservices Testing interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.