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.
Write a script that compares two API contract manifests (each describing endpoints, HTTP methods, required parameters, and response fields) and reports the differences between them. What normalization would you need to do first, and how does the approach scale as the manifests grow large?
Sample Answer
Direct answer
Below is a script that loads two API contract manifests and reports the differences between them: endpoints only in one, methods that changed, required parameters added or removed, and response fields added or removed.
Structured elaboration
The core idea is normalizing both manifests into the same comparable structure (a dictionary keyed by (path, method)) before diffing, so the comparison logic doesn't care what order the manifest's entries happened to be listed in.
Worked example
def normalize_manifest(manifest: list[dict]) -> dict:
"""Key each endpoint entry by (path, method) for order-independent comparison."""
return {(entry["path"], entry["method"]): entry for entry in manifest}
def diff_manifests(old_manifest: list[dict], new_manifest: list[dict]) -> dict:
old = normalize_manifest(old_manifest)
new = normalize_manifest(new_manifest)
old_keys = set(old.keys())
new_keys = set(new.keys())
report = {
"added_endpoints": sorted(new_keys - old_keys),
"removed_endpoints": sorted(old_keys - new_keys),
"changed_endpoints": {},
}
for key in sorted(old_keys & new_keys):
old_entry, new_entry = old[key], new[key]
old_params, new_params = set(old_entry.get("params", [])), set(new_entry.get("params", []))
old_resp, new_resp = set(old_entry.get("response", [])), set(new_entry.get("response", []))
changes = {}
if old_params != new_params:
changes["params_added"] = sorted(new_params - old_params)
changes["params_removed"] = sorted(old_params - new_params)
if old_resp != new_resp:
changes["response_fields_added"] = sorted(new_resp - old_resp)
changes["response_fields_removed"] = sorted(old_resp - new_resp)
if changes:
report["changed_endpoints"][f"{key[1]} {key[0]}"] = changes
return report
if __name__ == "__main__":
old_manifest = [
{"path": "/users", "method": "GET", "params": ["q"], "response": ["id", "name"]},
{"path": "/orders", "method": "POST", "params": ["userId"], "response": ["orderId"]},
]
new_manifest = [
{"path": "/users", "method": "GET", "params": ["q", "limit"], "response": ["id", "name", "email"]},
{"path": "/products", "method": "GET", "params": [], "response": ["id", "price"]},
]
import json
print(json.dumps(diff_manifests(old_manifest, new_manifest), indent=2))
Executed output:
{
"added_endpoints": [["/products", "GET"]],
"removed_endpoints": [["/orders", "POST"]],
"changed_endpoints": {
"GET /users": {
"params_added": ["limit"],
"params_removed": [],
"response_fields_added": ["email"],
"response_fields_removed": []
}
}
}
Normalization. Keying by (path, method) rather than comparing the manifests as raw lists is what makes the comparison correct regardless of ordering, without it, a manifest with the same endpoints listed in a different order would show up as entirely different.
Trade-offs and pitfalls
Complexity at scale. As written, this is O(n) in the number of endpoints, each manifest is normalized into a dict once (O(n)), and the diff itself is set operations over the keys, so it scales linearly and stays fast even for a manifest with thousands of endpoints. The part that DOESN'T scale as cleanly is a manifest where params or response fields are deeply nested objects rather than flat lists of names, this script's set-based comparison only detects a field being added or removed at the top level; a genuinely nested schema diff (a field's own sub-structure changing) needs a recursive comparison, which is real added complexity worth scoping in deliberately rather than assuming this flat version already handles it.
Explain consumer-driven contract testing: what problem it solves, how it prevents integration regressions between a service and its consumers, and what the consumer and provider sides are each responsible for in CI. What tooling typically supports it, and what pitfalls do teams commonly run into when adopting it?
Sample Answer
Direct answer
Consumer-driven contract testing (CDC) verifies that a service (the provider) still honors the expectations of the services that call it (the consumers) without spinning up the whole system. Each consumer records what it expects from the provider as a machine-readable "contract." The provider then replays that contract against its own code, in its own pipeline, to confirm it still satisfies it. Nobody has to run both services at once.
Structured elaboration
Why this exists. A full integration test where every dependent service is actually running catches real bugs, but it's slow, flaky, and gets worse as the number of services grows. Contract testing answers a narrower, cheaper question: does the provider still return what this specific consumer needs? That narrower question is fast to check and can run on every commit.
Division of responsibility.
- The consumer writes a test against a mock of the provider that asserts what it needs (a field, a status code, a shape). Running that test generates the contract as a byproduct, so the contract is always in sync with what the consumer's code actually depends on, not with what someone remembers to document.
- The provider takes that contract and replays it against its real implementation: it sets up whatever preconditions the contract's scenario needs (a "provider state," like "a user with ID 42 exists"), serves the request the contract describes, and checks the response matches.
- CI wiring on both sides is what makes this useful: the consumer's pipeline publishes new contracts when they change, and the provider's pipeline pulls and verifies against the latest contracts before it's allowed to deploy. A provider that would break a consumer fails its own build, not the consumer's.
Typical infrastructure. A contract-testing tool (Pact is the best known) plus a broker: a small service that stores contracts, tracks which provider version has verified which consumer version, and can answer "is it safe for me to deploy this provider version given what's currently in production?"
Benefits. Fast (no real network calls, no shared environment), and it fails on the side that actually caused the problem, since the provider's own CI is where a broken contract shows up.
Trade-offs and pitfalls
Contract tests are not a substitute for integration or end-to-end tests. They confirm the pieces individually honor their agreements; they don't prove the whole system behaves correctly when wired together, and they say nothing about things a contract can't easily express, like timing, ordering across multiple calls, or business-logic correctness. Common early-adoption pitfalls: contracts that are too strict (asserting on fields the consumer doesn't actually use, so unrelated provider changes fail verification for no reason) and contracts that are too loose (missing something the consumer genuinely depends on, so a real break slips through). A useful discipline is to only assert on what the consumer's code actually reads, nothing more.
Consumer-driven vs. provider-driven. In the consumer-driven model described above, the consumer's own tests generate the contract, so it's automatically grounded in real usage. A provider-driven model runs the other way: the provider maintains its own suite of contract tests representing what it believes its consumers need, without a consumer necessarily being involved. That's easier to set up when consumers can't or won't participate, but the contract can silently drift out of sync with what a consumer actually needs, since nothing forces it to be re-derived from real consumer code. Consumer-driven is preferable whenever you can get consumer teams to participate; provider-driven is a fallback when a consumer is external, unresponsive, or a system you don't control.
An API needs to keep serving multiple consumer versions while it evolves. Describe a test and rollout strategy that lets you change the API safely: how you would verify compatibility, what versioning policy you would use, and how you would eventually deprecate an old version without breaking consumers who haven't migrated yet.
Sample Answer
Direct answer
Supporting multiple consumer versions safely comes down to three disciplines working together: never breaking an existing contract without a deliberate compatibility decision, giving yourself a way to verify compatibility automatically before you ship, and having an explicit, time-boxed deprecation path so old versions don't live forever.
Structured elaboration
Compatibility as a policy, not an accident. Decide up front what counts as a non-breaking change (adding an optional field, adding a new endpoint, loosening a validation rule) versus a breaking one (removing or renaming a field, tightening validation, changing a status code's meaning). Non-breaking changes can ship freely; breaking changes require a plan.
Verification. Consumer-driven contract tests are the mechanism that actually catches a breaking change before it ships, every consumer's contract gets replayed against the provider's new code as part of CI, so a break shows up as a failed provider build rather than a production incident. For changes contracts don't naturally express well, compatibility tests that diff two versions of a schema or response shape catch structural drift contracts might miss.
Versioning policy. When a breaking change is genuinely necessary, introduce the new behavior as an addition (a new field, a new endpoint, a version header or path segment) rather than a replacement, so existing consumers keep working unmodified while new consumers can opt into the new shape. Feature flags let you roll the new behavior out gradually and roll it back cheaply if something's wrong.
Deprecation. A version that's meant to be temporary needs an actual end date and a way to know when it's safe to remove: instrument the old endpoint or field so you can see who's still calling it, communicate a deadline to whoever owns those consumers, and only retire it once usage has genuinely dropped to zero, not just once the deadline has passed on a calendar.
Trade-offs and pitfalls
Third-party or external consumers change this picture. When you're consuming, not providing, an API that itself exposes multiple versions with inconsistent behavior across them, for example an ad platform where different version endpoints sometimes return non-deterministic responses, you don't get to dictate compatibility policy to the other side. The mitigation shifts toward defense: a mock server standing in for each version in CI so your own tests stay fast and deterministic even though the real API isn't, an explicit version matrix so you know exactly which combinations you actually support, and an escalation path (alerting, a fallback behavior, a circuit breaker) for when a breaking change from the third party is detected in production despite your best efforts, since you often can't prevent it, only detect and react to it quickly. The common mistake in both directions is treating "backward compatible" as a vague intention rather than a specific, testable contract: without an automated check, compatibility decays the first time someone is in a hurry.
You depend on a downstream API that is slow and occasionally unreliable, and you want to isolate your tests from it without your stub silently drifting away from what the real service actually does. How would you set up and maintain that stub so it stays a faithful stand-in for the real API's contract?
Sample Answer
Direct answer
The goal isn't just to make the test pass against a fake version of the dependency, it's to make sure that fake stays faithful to what the real API actually does, so a passing test still means something. That takes a deliberate process for creating the stub and keeping it honest, not just hand-writing a plausible-looking response once.
Structured elaboration
Establishing a real baseline. Rather than guessing what the dependency's response looks like, record it from a real call (against a sandbox if one exists, or a controlled real call otherwise) and derive the stub's default behavior from that recording. This is what makes the stub a faithful stand-in rather than a convenient fiction.
Parameterizing the stub. A real dependency doesn't return the same thing for every input, it has different responses for different requests, and different failure modes (timeouts, specific error codes, malformed data). Configuring the stub to vary its response based on the incoming request, rather than always returning one canned reply, is what lets your tests exercise those different paths deliberately.
Stateful scenarios. Some dependencies have behavior that depends on sequence, a downstream service that returns "pending" the first few times you poll it and then "complete." A stub that can only return one fixed answer can't test that kind of flow; the stub needs to model a small state machine of its own to stand in convincingly.
Keeping the stub current. The real risk with any stub is drift: the real API changes and the stub quietly doesn't, so tests keep passing against a fiction. Mitigations that actually work: periodically re-recording the baseline against the real (or sandboxed) dependency and diffing it against the current stub configuration, and, where the provider publishes a machine-readable contract or schema, validating the stub's responses against that schema so a structural drift fails loudly instead of silently.
A concrete example. Configuring a stub for a slow downstream payment service, you'd want a mapping that returns a normal success response for the common case, an explicit configuration to simulate added latency (to test how your own service behaves under a slow dependency), and a specific trigger, keyed off something in the request itself, like a distinctive test value in place of a real identifier, that returns an error response, so a test can deliberately exercise the failure path without depending on the real dependency actually failing on demand.
Trade-offs and pitfalls
A stub that's too permissive (accepts anything, always returns success) gives false confidence: your tests pass, but they've stopped testing anything real about the integration. A stub that's too rigid (hardcoded to one exact request shape) breaks the moment your own client code changes something incidental, punishing you for changes that have nothing to do with the dependency. The balance point is a stub that's specific enough to catch a real contract violation but flexible enough not to be brittle against your own code's harmless changes, and periodically checked against the real dependency so that balance doesn't quietly decay.
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.
Unlock Full Question Bank
Get access to all 9 API and Contract Testing interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.