API and Contract Testing Questions
Testing services and their interfaces directly. Covers REST and other API testing, request/response and schema validation, status and error handling, and contract testing between producers and consumers. Includes service-level and integration testing without a UI.
You're adding a new field to a public JSON API response that third-party clients already consume in production. Walk through a rollout plan that keeps existing clients working: what you'd validate before shipping, how you'd communicate the change, and how you'd monitor for client-side errors afterward.
Sample Answer
Direct answer
Adding a field to a response third parties already consume in production is low-risk if it's purely additive and every client correctly ignores fields it doesn't recognize, but that assumption needs to be validated, not assumed, before shipping, since some client libraries are stricter than others about unexpected fields.
Structured elaboration
What to validate before shipping. Confirm the change really is additive: the new field doesn't replace or repurpose an existing one, and no existing field's meaning or type changes as a side effect. Then, ideally, test against a sample of real client behavior (or at least the client libraries you know are in wide use) to confirm they tolerate an unrecognized field gracefully rather than erroring on strict schema validation, some auto-generated client SDKs validate responses strictly against a fixed schema and will reject anything with an extra field.
Schema evolution. Update your own published schema (OpenAPI spec, or whatever you expose to consumers) to mark the new field explicitly optional, so anyone regenerating a client from your spec gets a client that correctly tolerates its absence on older cached responses too, if applicable.
Versioning or feature flags, if warranted. For a change this small and purely additive, most APIs don't need a full version bump, that's usually reserved for changes that aren't safely additive. But if there's real uncertainty about client tolerance, rolling the field out behind a flag to a small percentage of traffic first, watching for client-side errors, before enabling it broadly, is a reasonable middle ground between "just ship it" and "force every client to explicitly opt in."
Client communication. Even for an additive change, proactively telling consumers a new field is arriving, especially ones you have a direct relationship with, gives them a chance to flag if their integration is unusually strict, before it becomes an incident rather than after.
Monitoring after rollout. The rollout isn't done at deploy time. Watch for an increase in client-side error rates, support tickets, or (if you can see it) client SDK version adoption correlating with errors, in the period right after the field starts appearing, since a client that breaks on an unrecognized field will typically start failing immediately once it starts receiving the new shape, not gradually.
Trade-offs and pitfalls
The riskiest assumption in this whole plan is "clients ignore fields they don't recognize," and it's worth stress-testing that assumption specifically rather than trusting it by convention: a strict JSON Schema validator on the client side, or a statically-typed client generated from an older version of your spec with additionalProperties: false, can turn a technically-additive change into a real production break for exactly the consumers who were being the most rigorous about validation. If you have any visibility into what client tooling your major consumers use, checking that specifically, rather than assuming REST convention protects you, is the single highest-value step in this whole plan.
Design a set of test cases that verify an endpoint's idempotency guarantees for its POST, PUT, and DELETE operations. Cover retried requests, requests that arrive concurrently, and what should happen if a request partially succeeds before failing.
Sample Answer
Direct answer
Idempotency test cases need to cover three distinct failure shapes across all three methods this question names: a clean retry of an identical request, requests that genuinely arrive concurrently rather than sequentially, and a request that fails partway through processing. Each shape can break a naive idempotency implementation differently, and POST, PUT, and DELETE each need their own version of these tests because they can each fail in a way specific to that method.
Structured elaboration
Retries. The baseline case: send the same request (with the same idempotency key, if the endpoint uses one) twice in a row, and assert the end state and returned resource are identical to a single call, not that two resources got created or a value got applied twice.
Concurrent requests. This is the case a naive implementation most often gets wrong. Two identical requests arriving close enough together that a check-then-act pattern (query if this key/resource has been seen, then act if not) races: without a proper lock or a unique constraint at the database level, both requests can pass the check before either has recorded that it acted. A real test needs to fire requests concurrently, not just sequentially with no delay, to actually exercise this race rather than assume sequential retries cover it.
Partial failure. A request that starts processing, does part of the work, and then fails (a crash, a timeout, a downstream call that errors, or simply the response getting lost after the work already committed) before the client sees a result needs the retry that follows to behave correctly given that partial state: not double-apply the part that already succeeded, and not get stuck permanently rejecting retries because the original attempt never got to record that it completed.
POST. The clearest case, since POST isn't idempotent by default: without a client-supplied idempotency key, retrying a POST creates a second resource by design, so the test here is really testing that the idempotency-key mechanism layered on top works, not that raw POST does.
PUT. PUT is idempotent by contract (a full replace), but a common implementation bug breaks that contract silently: appending to a list field instead of replacing it. A single-call test can't see this, since one call's result looks correct either way. Only a repeat-call test, asserting the end state after two identical PUTs equals the end state after one, catches it.
DELETE. DELETE is idempotent in the sense that the end state ("resource doesn't exist") is unchanged by a repeat call, even though the response usually isn't: a first call typically returns success and a repeat typically returns 404. The test needs to assert the state convergence, not that both calls return the same status code.
Worked example
For an endpoint like POST /payments that accepts a client-supplied idempotency_key:
import concurrent.futures
import requests
def test_idempotent_post_retry(base_url, idempotency_key):
body = {"amount": 500, "idempotency_key": idempotency_key}
r1 = requests.post(f"{base_url}/payments", json=body)
r2 = requests.post(f"{base_url}/payments", json=body)
assert r1.status_code == 201
assert r2.status_code in (200, 201)
assert r1.json()["payment_id"] == r2.json()["payment_id"], "retry created a second payment"
def test_idempotent_post_concurrent(base_url, idempotency_key):
body = {"amount": 500, "idempotency_key": idempotency_key}
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
results = list(pool.map(
lambda _: requests.post(f"{base_url}/payments", json=body), range(5)
))
payment_ids = {r.json()["payment_id"] for r in results if r.status_code in (200, 201)}
assert len(payment_ids) == 1, f"concurrent retries created {len(payment_ids)} distinct payments"
The concurrent test is the one that actually exercises the race: firing 5 identical requests through a thread pool at (approximately) the same time, then asserting every successful response resolved to the same underlying resource rather than creating several. Run against a naive check-then-act payment store (an in-memory dict keyed by idempotency key, checked and written with no lock) this test genuinely fails, exposing 5 distinct payment IDs from 5 concurrent identical requests; the sequential retry test above, run against that exact same buggy store, passes every time, because the two calls never actually overlap. That's the concrete proof that a sequential-only retry test cannot detect this class of bug even though it superficially "tests retries." Against a version of the same store guarded by a lock, the concurrent test passes: exactly one payment ID.
Now PUT and DELETE, which the original version of this answer omitted:
import copy
# --- PUT: replace vs. a common "append instead of replace" bug ---
def put_replace(store, resource_id, body):
store[resource_id] = dict(body) # correct: whole-resource replace
return store[resource_id]
def put_append_bug(store, resource_id, body):
existing = store.setdefault(resource_id, {"tags": []})
existing["tags"] = existing.get("tags", []) + body.get("tags", []) # buggy: appends
return existing
def test_put_idempotent(put_fn):
store = {}
body = {"tags": ["a", "b"]}
r1 = copy.deepcopy(put_fn(store, 42, body))
r2 = copy.deepcopy(put_fn(store, 42, body))
assert r1 == r2, f"repeated identical PUT produced different states: {r1} vs {r2}"
# --- DELETE: state converges even though the response code doesn't ---
def delete_resource(store, resource_id):
existed = resource_id in store
store.pop(resource_id, None)
return 200 if existed else 404
def test_delete_idempotent():
store = {99: {"name": "Ada"}}
first = delete_resource(store, 99)
second = delete_resource(store, 99)
assert first == 200 and second == 404, "expected success then not-found"
assert 99 not in store, "resource should be gone after either call"
Executed: test_put_idempotent(put_replace) passes. test_put_idempotent(put_append_bug) fails with repeated identical PUT produced different states: {'tags': ['a', 'b']} vs {'tags': ['a', 'b', 'a', 'b']}, exactly the silent bug described above, caught only because the test calls PUT twice and compares snapshots rather than trusting a single call. test_delete_idempotent passes: 200 then 404, and the resource is confirmed gone either way.
Finally, partial failure, made concrete instead of only described:
class PaymentProcessor:
"""LEDGER models the real downstream charge; STORE models the idempotency record.
Both are written in the same server-side operation, before any response is
returned to the client, which is what makes a "lost response" recoverable on
retry without double-charging."""
def __init__(self):
self.ledger = []
self.store = {}
def charge(self, idempotency_key, amount, simulate_lost_response=False):
if idempotency_key in self.store:
return self.store[idempotency_key] # retry: already completed
self.ledger.append(amount) # the actual charge
result = {"payment_id": f"pay_{len(self.ledger)}", "amount": amount}
self.store[idempotency_key] = result # committed before returning
return None if simulate_lost_response else result
def test_partial_failure_no_double_charge():
proc = PaymentProcessor()
key = "order-77"
r1 = proc.charge(key, 500, simulate_lost_response=True) # charged, but ack "lost"
assert r1 is None
r2 = proc.charge(key, 500) # client retries after timeout
assert r2 is not None and r2["amount"] == 500
assert len(proc.ledger) == 1, f"expected exactly one real charge, got {len(proc.ledger)}"
Executed: passes, confirming exactly one entry in ledger even though the client never saw the first response and retried. The mechanism this demonstrates: the idempotency record has to be committed durably in the same operation as the side effect, before the response is sent, so a response that gets lost in transit is still recoverable on retry without re-doing the side effect.
Trade-offs and pitfalls
A payment-domain example makes the partial-failure case concrete: if a payment provider charges a card but the response to your service times out before you've recorded a success, a naive retry will attempt to charge the card again unless the idempotency key is checked, and honored, on the provider's side too, not just your own. The PaymentProcessor example above shows the minimal shape of getting this right on your own side (write the idempotency record atomically with the side effect); a real integration additionally depends on the upstream provider offering the same guarantee, which is worth verifying rather than assuming.
A service you depend on keeps shipping changes that break your integration with it, and the breakage is only caught after it reaches production. Propose both a technical and a process fix: what would you put in place so a breaking change is caught before it ships, who should own the tests that catch it, and how would you pilot the change and show it actually reduced regressions?
Sample Answer
Direct answer
The fix has to work on two tracks at once: a technical track that catches a breaking change before it ships, and a process track that makes catching it everyone's default rather than something only found in a postmortem. Neither alone is durable, technical checks without ownership get bypassed under deadline pressure, and process without automation depends on someone remembering to look.
Structured elaboration
Technical track. Add consumer-driven contract tests to the dependency relationship: the depending service's real expectations get encoded as a contract, and the upstream service's CI verifies against it before every deploy. This is what actually blocks a breaking change from shipping, rather than catching it after the fact in a bug report. Where a contract test isn't practical to set up quickly, an automated compatibility check that diffs the API's schema or response shape between versions is a cheaper first step that still catches the most common failure mode: a field silently removed, renamed, or retyped.
CI policy. The upstream service's pipeline should fail the build, not just warn, when provider verification against a downstream consumer's contract fails. Placement matters: this check belongs in the upstream service's own CI, where the change originates, not bolted onto the downstream service's pipeline as an afterthought.
Process track. Even a working technical check needs an owner. A PR template or review checklist item that asks "does this change anything a documented consumer depends on" catches the cases contract tests haven't been written for yet. Clear ownership, who's accountable when a contract test fails, and who's accountable for keeping the downstream service's contract accurate as its own needs evolve, is what prevents the check from silently rotting once nobody's paying attention to it.
Retrofitting onto an org with no prior governance. When there's no existing discipline at all, start with a migration plan rather than a mandate: pick the highest-incident-risk dependency pairs first (the ones that have actually caused regressions), prove contract testing catches something real there, and use that as the case for expanding rather than rolling it out everywhere simultaneously with no working example.
Piloting and measuring. Run the new discipline on one or two service pairs first. Track something concrete before and after: the number of contract-related production incidents on those pairs, or the number of contract-test failures caught in CI versus caught in production. That comparison is what actually demonstrates the pilot worked, rather than assuming it did because the process feels more rigorous.
Trade-offs and pitfalls
The most common way this fails is treating it as a one-time cleanup instead of an ongoing discipline: teams write contract tests once, the regressions stop for a while, and the checklist item quietly gets skipped under the next deadline because nothing enforces it anymore. Ownership needs to be durable, not a one-time assignment, and the CI gate needs to actually block a deploy, not just post a warning that's easy to ignore.
Design a consumer-driven contract testing rollout for an organization with dozens to hundreds of microservices owned by different teams. Cover how contracts are authored and versioned, how they are stored and published, what a provider verification pipeline looks like, and how you would handle a backward-incompatible change without breaking a deployment.
Sample Answer
Direct answer
At organizational scale, a contract testing rollout has three parts working together: a clear authoring and versioning discipline for contracts, a broker as the shared source of truth, and CI gates on both the consumer and provider side that actually block a bad deploy rather than just reporting on it after the fact.
Structured elaboration
Authoring and ownership. Each consumer team owns the contracts that describe what it needs from a provider; each provider team owns making its own CI verify against every contract published against it. This is what keeps contracts from silently drifting out of sync: since a consumer's contract is generated by running its own test, it's grounded in what the code actually depends on, not documentation someone forgot to update.
Storage and versioning. A broker stores every contract, tagged by consumer version and branch, and every verification result, tagged by which provider version verified against which consumer version. This is the piece that scales the approach past a handful of services: instead of everyone needing to know everyone else's state, the broker answers "is it safe for provider version X to deploy, given what consumer versions are actually running in production right now" as a single query, sometimes called a can-I-deploy check.
CI integration. Two hooks matter. On the consumer side, a merge to main publishes the new contract to the broker. On the provider side, the pipeline pulls the latest relevant contracts and runs provider verification, replaying each contract's interactions against the real service, before allowing the build to proceed. Verification timing is usually split: on every pull request against the most recent contracts (fast feedback), and again on a schedule or before release against whatever's currently deployed (catching drift).
Handling backward-incompatible change. When a provider needs to make a breaking change, the discipline is to introduce the new behavior alongside the old one (an additive change, a new field, a new version), get every affected consumer to update and re-verify against the new shape, and only then retire the old behavior once the broker shows no live consumer still depends on it. The can-I-deploy check is what makes this safe to do incrementally rather than as a coordinated big-bang release.
Handling mismatches in CI. When provider verification fails, that failure belongs in the provider's own build, not the consumer's, and the pipeline should block the provider's deploy rather than let it ship and only fail visibly in production. Multiple consumers with conflicting expectations for the same interaction is a real failure mode: it needs to be resolved as a genuine compatibility conversation between teams, not silently overridden by whichever contract happened to verify last.
Trade-offs and pitfalls
The two failure modes worth naming explicitly: rolling this out with no governance, so every team invents its own conventions and the broker becomes noise instead of a source of truth, and rolling it out as pure tooling with no CI enforcement, so contracts exist but nothing actually blocks a bad deploy, which teaches everyone to ignore them. Retrofitting this onto an organization with many existing services and no prior contract-testing practice works better as a staged migration: pick a handful of well-understood, high-change-frequency service pairs first, prove the workflow and the can-I-deploy gate actually catch something real, then expand, rather than mandating it everywhere at once with no working example to point to.
Several consumer teams have each published a contract against the same provider API, and their expectations conflict with each other. How would you detect that conflict, and what technical and organizational approaches would you use to resolve it without breaking any of the consumers already in production?
Sample Answer
Direct answer
Detecting the conflict is the easy part: run every published contract's interactions against the provider and look for contradictory expectations on the same request, one consumer expecting field A present, another expecting it absent, for example. Resolving it is the harder part, and it's fundamentally a negotiation between teams that tooling can only support, not replace.
Structured elaboration
Detection. Because every consumer's contract is verified independently against the same provider code, a genuine conflict shows up as two contracts making incompatible assertions about the same request/response pair. Comparing contracts pairwise, or having the broker (the shared contract-testing service, such as a Pact Broker, that every consumer publishes its contract to and every provider's build verifies against) flag interactions with overlapping requests but divergent expected responses, surfaces this automatically rather than waiting for it to show up as a production bug for one side or the other.
Version negotiation. Once a conflict is found, the technical fix is usually to stop treating "the provider's response" as one fixed shape and instead let it vary by what the caller actually needs: a version header or content-negotiation scheme, so consumer A gets the shape it expects and consumer B gets a different, equally valid shape, from the same underlying data. This works well when the conflict is really "two valid interpretations of the same data" rather than one side simply being wrong.
Backward and forward compatibility. The provider's own compatibility policy determines how much freedom there is here: if it commits to strict backward compatibility, existing consumer expectations become close to a hard constraint on any change, and new consumer needs have to be satisfied additively. If it's willing to introduce controlled breaking changes on a schedule, the negotiation instead becomes "which consumers need to migrate, and by when": publish the new behavior alongside the old one, give every affected consumer an explicit deadline, track which consumers have actually migrated (not just whether the deadline has passed), and only retire the old behavior once real usage has dropped to zero.
Preventing breakage during resolution. Whatever the resolution turns out to be, it needs to ship without breaking either consumer mid-negotiation. In practice that means introducing the resolved behavior as an addition, a new field, a new version, alongside the existing ones, verifying all affected contracts against it, and only removing the old behavior once every consumer that depended on it has migrated and its contract has been updated to prove that.
Organizational side. Technically resolving the conflict doesn't resolve WHY it happened. A conflict is often a sign that two consumer teams have differing, undocumented assumptions about what the provider is supposed to guarantee. The organizational fix is making the provider's actual contract, the intersection of what it's willing to promise, visible and owned, so the next new consumer integrates against a documented reality instead of guessing and creating the next conflict.
Trade-offs and pitfalls
Silently picking a winner, quietly changing the provider to satisfy whichever contract verified most recently, and letting the other consumer's contract simply start failing, is a bad outcome even though it looks like progress in CI (one team's test starts passing). It just moves the conflict onto the consumer whose contract now fails, with no one deciding that trade-off deliberately. The discipline that prevents this is treating any newly-conflicting contract as a signal to open a conversation between the teams involved, not as a bug in one contract to be silenced.
Unlock Full Question Bank
Get access to all 18 API and Contract Testing interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.