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.
Write a small Node.js (Express) mock server that can be used as service virtualization for an external payment gateway. The mock must support a configurable HTTP response status and body per endpoint, a configurable response delay, and a webhook endpoint to simulate asynchronous callbacks. Show how behavior can be configured via environment variables or a JSON config file, and how you'd run it in CI.
Sample Answer
Direct answer
A small Express server below implements exactly this: per-endpoint configurable status/body/delay driven by a JSON config file (overridable via environment variables), plus a webhook-simulation endpoint that schedules a delayed callback so tests can exercise the real asynchronous-confirmation shape a payment gateway actually has. All three behaviors (configurable status, configurable delay, and the async webhook) were executed and verified against real HTTP requests, not just read for plausibility.
Structured elaboration
- Configuration-driven routing. Routes and their behavior are data (a JSON config), not hardcoded per-endpoint logic, so adding or changing a simulated endpoint's behavior is a config change, not a code change; this matters for a mock server used across many test scenarios, where different tests need the same route to behave differently (success, then a later test needs it to fail).
- Configurable delay. Every route can specify a
delayMs; the handler actually waits that long viasetTimeoutbefore responding, so tests that need to assert timeout-handling behavior against a slow gateway have a real, measurable delay to work with. - The webhook as its own, separately-triggered concern. Real payment gateways confirm a charge via an asynchronous webhook sent some time after the initial API call returns, not synchronously in the response; the mock models this explicitly with a
/_simulate/webhookendpoint a test calls to schedule that callback, plus a/_simulate/webhook-logendpoint tests can poll to confirm it fired, matching the eventual-consistency testing pattern used elsewhere for async confirmations.
Worked example (Node.js/Express, executed)
// mock-payment-gateway.js
const express = require('express');
const fs = require('fs');
function loadConfig(path) {
return JSON.parse(fs.readFileSync(path, 'utf8'));
}
function createApp(config) {
const app = express();
app.use(express.json());
const webhookLog = [];
for (const [routeKey, behavior] of Object.entries(config.routes || {})) {
const [method, routePath] = routeKey.split(' ');
app[method.toLowerCase()](routePath, (req, res) => {
setTimeout(() => res.status(behavior.status).json(behavior.body), behavior.delayMs || 0);
});
}
app.post('/_simulate/webhook', (req, res) => {
const { event, payload, delayMs } = req.body;
const record = { event, payload, firedAt: null, scheduledAt: Date.now(), delayMs: delayMs || 0 };
webhookLog.push(record);
setTimeout(() => { record.firedAt = Date.now(); }, delayMs || 0);
res.status(202).json({ accepted: true });
});
app.get('/_simulate/webhook-log', (req, res) => res.status(200).json(webhookLog));
return app;
}
module.exports = { createApp, loadConfig };
if (require.main === module) {
const config = loadConfig(process.env.MOCK_CONFIG_PATH || `${__dirname}/mock-config.json`);
createApp(config).listen(process.env.PORT || 4010);
}
// mock-config.json
{
"routes": {
"POST /v1/charges": { "status": 201, "body": { "id": "ch_test_1", "status": "pending" }, "delayMs": 50 },
"POST /v1/charges/timeout-case": { "status": 504, "body": { "error": "gateway_timeout" }, "delayMs": 300 }
}
}
Running a verification script against this exact server (starting it on an ephemeral port and making real fetch calls) printed:
PASS: POST /v1/charges returned configured 201 + pending body
PASS: POST /v1/charges/timeout-case delayed ~304ms and returned 504 as configured
PASS: webhook fired 101 ms after being scheduled (configured 100ms)
ALL CHECKS PASSED
confirming the configurable status/body, the configurable delay (measured wall-clock delay of ~304ms against a configured 300ms, well above the 250ms floor a flaky measurement could produce), and the asynchronous webhook firing after its scheduled delay all behave as specified. To run it in CI: node mock-payment-gateway.js & (or a container), point the system under test's payment-gateway base URL at it, and call /_simulate/webhook from the test after the initial charge call returns.
Trade-offs and pitfalls
- The delay is implemented with
setTimeout, which is accurate enough for test purposes but not a hard real-time guarantee; assertions on measured delay should use a generous floor (as shown, checkingelapsed > 250against a configured 300ms, not an exact equality) to avoid flakiness from scheduler jitter. - This mock does not verify webhook signatures the way a real gateway would; if the system under test validates a signature header, extend the mock to compute and attach a matching signature (using the same shared secret / algorithm the real gateway uses) or the mock will silently fail to exercise that code path.
- Because routes are purely config-driven, a test that needs the SAME route to behave differently at two different points in one test run needs either two separate config files loaded into two separate mock instances, or a small addition to the mock's API to let a test dynamically override one route's behavior mid-run; the version shown here assumes one fixed configuration per server instance/test run.
For a system composed of many event-driven microservices communicating over a message bus such as Kafka, design a testing strategy that covers component, contract, and end-to-end behavior for the asynchronous flows. Explain how you would verify correct handling of retries and consumer failure so that reprocessing a message never corrupts state, how you'd build deterministic test harnesses for events, and how you'd validate cross-service contracts while avoiding brittle, slow end-to-end tests.
Sample Answer
Direct answer
Layer the strategy in three tiers: fast component tests against each service's message-handling logic in isolation, contract tests validating each service correctly produces and consumes the event schema it's supposed to, and a small number of deliberately-designed end-to-end tests for the specific async behaviors (ordering, retries, dead-letter handling) that only show up when the whole pipeline runs together; and treat idempotency under retry-driven reprocessing as its own explicit, first-class test category, not an assumed side effect of the other layers.
Structured elaboration
- Component tests. Test each service's message handler as a pure function wherever possible: given this event payload, what side effects and what outgoing events does it produce? No real message bus involved. These are fast, catch the majority of logic bugs, and should be the bulk of the test suite by count.
- Contract tests. For every event a service PRODUCES, verify its schema against what consumers expect (consumer-driven contract testing, run in CI against a broker); for every event a service CONSUMES, verify it correctly handles the schema its producers actually emit, including fields the consumer doesn't currently use (forward compatibility). This is the layer that catches "team A changed an event's shape and broke team B" before it reaches a shared environment.
- End-to-end tests, deliberately few and deliberately targeted. Reserve full pipeline tests for the properties that CANNOT be verified any other way: does a retried message get reprocessed idempotently across the real chain of services (not just within one service's own logic), does an unprocessable message correctly reach a dead-letter destination without blocking the topic, does the pipeline preserve per-key ordering under real broker delivery. Keep this tier small and deterministic (seeded IDs, a bounded number of scenarios) specifically because full-pipeline tests are the slowest and most flake-prone layer; the goal is confidence about pipeline-level emergent behavior, not re-testing logic component tests already cover.
- Idempotency under reprocessing, explicitly. A message that is retried (because a consumer crashed, timed out, or the broker redelivered under its at-least-once guarantee) must not corrupt state on a second application. Test this directly: apply the same message twice through the real pipeline (not just one service in isolation) and assert the observable end state is identical to applying it once.
Worked example
A compact deterministic test-harness fixture, showing all three tiers acting on the same underlying event schema:
# Tier 1: component test, no bus involved
def test_pricing_service_applies_discount_correctly():
handler = PricingHandler()
result = handler.handle(OrderPlaced(order_id="o1", amount=100, discount_code="SAVE10"))
assert result.emitted_event == PriceCalculated(order_id="o1", final_amount=90)
# Tier 2: contract test, checking the schema a consumer actually receives
def test_pricing_service_event_matches_consumer_contract(pact_broker):
contract = pact_broker.get_contract("pricing-service", "ledger-service")
sample_event = PricingHandler().handle(sample_order()).emitted_event
assert contract.validates(sample_event)
# Tier 3: small, targeted end-to-end test for retry-driven idempotency across
# the REAL chain of services, not one service's internal logic
def test_redelivered_order_event_does_not_double_charge_end_to_end(live_pipeline):
order = sample_order(order_id="o-e2e-1")
live_pipeline.publish(OrderPlaced(**order))
live_pipeline.publish(OrderPlaced(**order)) # deliberate redelivery, same event id
live_pipeline.wait_for_settled(order["order_id"], timeout_s=5.0)
assert live_pipeline.ledger_balance_delta(order["order_id"]) == 90, (
"the same order event delivered twice must not be charged twice"
)
Trade-offs and pitfalls
- Skipping the contract-testing tier is the single most common way this strategy fails in practice: without it, a schema change is only caught by the slow, flaky end-to-end tier, or worse, not caught until it reaches a shared environment.
- Making the end-to-end tier too large (trying to re-verify every business rule through the full pipeline) reintroduces exactly the slow, brittle test suite this three-tier strategy is meant to avoid; keep it to the handful of properties that are genuinely emergent across services.
- Idempotency testing must exercise the REAL chain, not a single service's mock of "what the rest of the pipeline would do"; a service that is idempotent in isolation can still produce a double effect if a DOWNSTREAM service it calls is not, so the end-to-end idempotency test above is not optional even when every component test already passes.
Design a shared 'mock-as-a-service' platform that teams can use to host virtualized third-party APIs for integration tests. Address the interface for creating mocks, versioning and lifecycle management, multi-tenant isolation, scaling, persistent request logs for assertions, and access control integration with CI systems.
Sample Answer
Direct answer
Design the platform around three axes: an API for defining a mock's behavior (routes, responses, versioned configurations) that teams manage like any other artifact, strict multi-tenant isolation (each team's mocks and their request logs are invisible to every other team by default), and horizontal scalability of the request-serving layer independent of the configuration-management layer, with access control integrated into the same CI identity teams already use elsewhere.
Structured elaboration
- Interface for creating, versioning, and retiring mocks. Teams define a mock as a declarative configuration (routes, response bodies, status codes, delays) submitted through an API or a config-as-code repository; each mock configuration is versioned, so a team can roll back a bad mock update, and CI can pin a specific mock version for a specific test run rather than depending on whatever the "latest" mock happens to be at the moment a test runs. Lifecycle management is the other half of this: a mock configuration should move through explicit states (active, deprecated, retired) rather than accumulating indefinitely; when a team stops needing a mock (the real third-party integration is decommissioned, or a mock version is superseded), the platform should let them mark it deprecated, notify any CI job still pinned to that version, and eventually garbage-collect versions unused past some retention window, so a shared platform used by dozens of teams doesn't quietly accumulate years of abandoned mock configurations that nobody remembers owning.
- Multi-tenant isolation. Each team's mocks live in their own namespace: request routing, mock storage, and request logs are all scoped to that namespace, and cross-namespace access requires an explicit grant. This matters because a shared platform used by many teams is exactly the kind of infrastructure where one team's noisy or misconfigured mock could otherwise silently affect another team's tests if isolation is weak.
- Scaling. Separate the (relatively low-volume, low-latency-sensitive) configuration-management API from the (potentially high-volume, latency-sensitive) request-serving data plane; the data plane should scale horizontally behind a load balancer independent of how often configurations change, since CI running thousands of tests concurrently can generate significant request volume against the mock layer even though mock CONFIGURATION itself changes relatively rarely.
- Persistent request logs for assertions. Every request the mock platform receives is logged (with namespace, timestamp, matched route, and payload) and retained long enough for a test to query it afterward and assert "was this specific call made, with this payload, exactly once," which is often the actual assertion a consuming test needs, not just "did the mock respond correctly."
- Access control integration with CI. Authenticate and authorize requests to both the configuration API and the data plane using the same identity/CI-token mechanism teams already use for their pipelines, so a team's CI job can only read/write its own namespace's mocks without a separate, platform-specific credential to manage.
Worked example
A sketch of the core data model and one representative API surface, illustrating the versioning and isolation concerns concretely:
MockConfig {
namespace: string // e.g. "team-payments"
mock_id: string
version: int // incremented on every update; immutable once created
status: enum // active | deprecated | retired
routes: [ { method, path_pattern, status, body, delay_ms } ]
created_by: string
created_at: timestamp
}
RequestLogEntry {
namespace: string
mock_id: string
matched_version: int // which version of the mock actually served this request
method, path, request_body, response_status
received_at: timestamp
}
# Example CI usage: pin a specific mock version for reproducibility, make a
# call through the system under test, then assert on the platform's own
# persistent request log rather than re-implementing assertion logic per team.
mock_client.activate_version(namespace="team-payments", mock_id="payment-gateway", version=7)
response = system_under_test.create_charge(amount=100)
logged = mock_client.query_requests(namespace="team-payments", mock_id="payment-gateway", since=test_start_time)
assert len(logged) == 1
assert logged[0].request_body["amount"] == 100
Trade-offs and pitfalls
- Building a shared platform is a meaningfully larger investment than a per-team local mock server; it earns its cost primarily when many teams independently need to virtualize similar third-party dependencies (shared payment gateways, shared identity providers) and would otherwise duplicate the effort, so validate that need before committing to platform-scale infrastructure over a simpler per-team approach.
- Weak multi-tenant isolation (a shared mock namespace, or request logs visible across teams) is the most damaging failure mode for a shared platform, since it can silently leak one team's test data into another's assertions; treat isolation as a hard requirement to verify with its own dedicated tests, not an assumed property of the design.
- Persistent request logs accumulate quickly under high CI volume; define a retention policy (and make it explicit to consuming teams) so "query requests since test start" remains fast and teams don't build assertions that assume unbounded retention.
When integration tests spanning multiple services fail, what specific observability signals and logs should your tests capture or attach to CI artifacts to speed root-cause analysis? Give concrete examples of the kinds of diagnostic data you'd collect.
Sample Answer
Direct answer
Capture, on any multi-service integration-test failure, a correlation ID that ties every log line and trace span across all involved services to this one test's request, the raw request and response bodies at each service boundary the request crossed, and the message payloads for any asynchronous hop involved, attaching all of it to the CI run's failure artifacts automatically rather than leaving a developer to manually chase logs across several services after the fact.
Structured elaboration
- Correlation ID as the anchor. Generate a unique ID per test (or reuse the test's own deterministic entity ID) and thread it through every service the request touches, as a header on synchronous calls and a field on any published event; every piece of diagnostic data below should be findable by searching for this one ID.
- Request/response capture at each boundary. Log (or have the test harness itself capture, via a logging proxy in front of each service) the raw request and response at every service-to-service HTTP boundary the failing scenario crossed, so a developer can see exactly what each service actually sent and received, not just what it was expected to send.
- Message payload capture. For any asynchronous hop, capture the actual message payload that was published and, separately, what each consumer actually processed, since a mismatch between the two (a payload the consumer received differing from what was published) is itself diagnostic information a developer needs.
- System metrics as context. Attach relevant system-level metrics from the failure window (CPU, memory, queue depth) for the involved services, since a failure caused by resource exhaustion looks very different in the logs than a pure logic bug, and having the metrics alongside the logs shortens that diagnosis considerably.
- Automatic attachment to CI artifacts. Wire the capture and collection above into the test harness itself, so a failure automatically produces a bundle (or a single queryable link, if your observability backend supports deep-linking by correlation ID) attached to the CI run, rather than requiring someone to manually go re-run the scenario with extra logging enabled after the fact, by which point the original failure's specific conditions may no longer be reproducible.
Worked example
def test_capture_diagnostics_on_failure(request_capturing_proxy, tracing_client):
correlation_id = str(uuid.uuid4())
try:
response = checkout_client.place_order(sample_order(), correlation_id=correlation_id)
assert response.status == "confirmed"
except AssertionError:
bundle = {
"correlation_id": correlation_id,
"http_exchanges": request_capturing_proxy.get_exchanges(correlation_id),
"trace": tracing_client.get_trace_by_correlation_id(correlation_id),
"published_messages": message_bus_recorder.get_by_correlation_id(correlation_id),
}
attach_to_ci_artifacts(f"failure-{correlation_id}.json", bundle)
raise
Trade-offs and pitfalls
- Capturing full request/response bodies for every test run (not just failures) can be expensive at scale and may capture sensitive data; capture on FAILURE only (as shown) where possible, and scrub known-sensitive fields even then.
- This capture mechanism is only as complete as the correlation ID's propagation; a service that drops or fails to forward the correlation header breaks the chain at exactly the point a developer most needs it, so treat correlation-ID propagation itself as something worth its own dedicated test.
- A diagnostic bundle that is large and unstructured is nearly as unhelpful as no bundle at all; keep the bundle's structure consistent and queryable (as the JSON shape above does) so a developer can quickly find the one relevant exchange rather than reading a large undifferentiated log dump.
Design tests to verify the idempotency of an API that may receive duplicate requests from client retries. Cover the happy-path idempotent case, an operation that only partially applied before a retry arrived, concurrent duplicate requests racing each other, and any time-windowed deduplication strategy the API might use.
Sample Answer
Direct answer
Test idempotency across four distinct scenarios, each with its own assertion: a clean happy-path duplicate (same key, request fully completed before the retry arrives, must return the identical prior result without reapplying the effect), a partially-applied operation (a crash between the effect and recording completion, the retry must safely complete or safely no-op, never double-apply), concurrent duplicates racing each other (both arriving before either has recorded completion, exactly one must "win" the actual side effect), and the dedup window's own boundary (a duplicate arriving just inside versus just outside the window).
Structured elaboration
- Clean duplicate (sequential). Submit a request, let it complete, then submit the identical request (same idempotency key) again. Assert the second call returns the SAME result as the first (not a new one), and assert the underlying effect (a charge, a row insert) happened exactly once by checking the actual side effect's count/state, not just the API's returned status code.
- Partially-applied operation. Simulate a crash between the side effect executing and the idempotency record being durably written (the same seam-based fault injection used elsewhere for partial-failure testing), then submit the retry, and assert the system either safely completes the interrupted operation (if it can detect and resume) or safely re-applies it exactly once from scratch (if it cannot detect partial completion and must restart cleanly), but never ends up double-applying the partial work plus the full retry.
- Concurrent duplicates. Fire the identical request (same key) from two threads simultaneously, before either has had a chance to record its own idempotency entry, and assert exactly one side effect occurred; this specifically tests whether the idempotency check-then-act sequence itself has its own race condition (a naive "check if key exists, if not proceed" is vulnerable to both concurrent callers passing the check before either writes its record).
- Dedup-window boundary. If the idempotency mechanism uses a time-windowed dedup (rather than permanent storage of every key ever seen), test a duplicate arriving just inside the window (must be deduped) and one arriving just after the window expires (the system's documented behavior for this case, likely treated as a new, distinct request, which is itself worth asserting explicitly rather than assuming).
Worked example
import threading
def test_concurrent_duplicate_requests_apply_effect_exactly_once():
store = IdempotentApiStore()
results = []
barrier = threading.Barrier(2)
def submit():
barrier.wait() # force both requests to race the idempotency check simultaneously
results.append(store.process_request(idempotency_key="req-1", payload={"amount": 10}))
t1 = threading.Thread(target=submit)
t2 = threading.Thread(target=submit)
t1.start(); t2.start(); t1.join(); t2.join()
assert results[0] == results[1], "both callers must observe the identical result"
assert store.effect_count("req-1") == 1, (
f"expected the side effect to occur exactly once under a genuine race, got {store.effect_count('req-1')}"
)
Trade-offs and pitfalls
- Testing only the sequential duplicate case (submit, wait for completion, submit again) is the easiest to write and the least informative; the concurrent-duplicate case above is what actually exercises the idempotency mechanism's own internal correctness, and is where naive "check-then-act" implementations most often fail.
- Simulating a partial-application crash requires a controllable fault-injection seam in the code path; without one, this specific scenario is difficult to test with real fidelity.
- Dedup-window boundary testing needs a controllable clock (rather than real elapsed time) to reliably test "just inside" versus "just outside" the window without a slow, flaky real-time-based test.
Unlock Full Question Bank
Get access to all 39 Distributed Systems and Microservices Testing interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.