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.
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.
What does a solid test-data strategy for API and service tests actually look like in practice? Cover how you'd decide between generating data on the fly versus using fixed fixtures, when synthetic data is good enough versus when you need something closer to real production data, and how you keep tests from interfering with each other's data.
Sample Answer
Direct answer
A solid test-data strategy for API and service tests rests on four decisions made deliberately rather than by default: generate-on-the-fly versus fixed fixtures, synthetic versus production-derived data, how tests stay isolated from each other's data, and what actually needs cleanup versus what can be safely shared.
Structured elaboration
Generated on the fly vs. fixed fixtures. A fixture generated fresh for each test (a new user created at the start of the test, torn down at the end) gives strong isolation, no test can be affected by another test's leftover state, at the cost of some setup time per test. A fixed, shared fixture (a small set of known reference records seeded once) is faster to use repeatedly but risks one test's assumptions about that shared data becoming invalid if another test modifies it. The practical split: generate fresh data for anything a test needs to MUTATE, and use shared fixtures only for read-only reference data nothing is expected to change.
Synthetic vs. closer-to-production data. Purely synthetic data (templated names, generated emails) is simplest and carries no privacy risk, but can miss bugs that only show up with realistic value distributions or realistic correlations between fields. Production-derived data (masked or anonymized) is more realistic but requires real discipline around what's actually safe to use in a test environment, and is usually reserved for a smaller number of higher-fidelity tests (load testing, a final pre-release check) rather than the bulk of the day-to-day suite, where synthetic data's speed and simplicity matter more than its lower realism.
Isolation between tests. However the data is generated, tests running concurrently need to not collide: namespacing (a unique prefix or identifier per test run), using ephemeral per-test-run resources (a fresh database schema, a dedicated test namespace), or simply ensuring every generated record's identifiers are unique enough that two tests' data can coexist without either noticing the other.
Cleanup. Data a test creates should be removed when the test finishes, via the test's own teardown when things go well, and via a backstop (a scheduled sweep for anything matching a test-data naming pattern and older than some threshold) for the cases where a test crashes before its own teardown runs. Shared, read-only reference data doesn't need this kind of cleanup at all, that's part of why the mutate-vs-read-only split above matters: it determines which data even needs a cleanup story.
Trade-offs and pitfalls
The mistake that causes the most downstream pain is treating "test data strategy" as one uniform decision applied to everything, generate everything fresh, or share everything, rather than recognizing that different data has different needs. A shared reference fixture that's occasionally, accidentally mutated by a test that was supposed to only read it is one of the more confusing categories of flaky test to debug, since the failure shows up in a DIFFERENT, unrelated test that happened to run afterward and inherited the corrupted shared state, not in the test that actually caused the problem.
Write a test that consumes a paginated API and confirms every item is returned exactly once, with no duplicates and nothing missing. Handle the case where the API might use either page-number pagination or a next-page token.
Sample Answer
Direct answer
Verifying completeness across pagination means tracking every item you've seen by its unique ID as you walk the pages, then asserting at the end that the set of IDs collected matches the full expected set exactly, no duplicates and nothing missing, rather than just checking that each individual page request succeeded.
Structured elaboration
The two pagination styles need different loop-termination logic but the same completeness check underneath: page-number pagination stops when a page comes back empty (or when you've reached a known total), token-based pagination stops when next_page comes back null. A subtlety worth being deliberate about: the collection step itself must NOT silently deduplicate as it goes, if it does, a real bug where the API returns the same item twice across two pages gets quietly absorbed instead of caught. Collect everything exactly as returned, then check for duplicates and completeness as a separate step afterward.
Worked example
import requests
def fetch_all_items(base_url, use_tokens: bool):
# No dedup here on purpose: deduping while collecting would hide a
# duplicate-across-pages bug instead of surfacing it.
collected_ids = []
if use_tokens:
next_page = None
while True:
params = {"next_page": next_page} if next_page else {}
resp = requests.get(f"{base_url}/items", params=params)
data = resp.json()
collected_ids.extend(item["id"] for item in data["items"])
next_page = data.get("next_page")
if not next_page:
break
else:
page = 1
while True:
resp = requests.get(f"{base_url}/items", params={"page": page, "page_size": 50})
data = resp.json()
if not data["items"]:
break
collected_ids.extend(item["id"] for item in data["items"])
page += 1
return collected_ids
def test_pagination_completeness_no_duplicates(base_url, expected_ids: set, use_tokens: bool):
collected = fetch_all_items(base_url, use_tokens)
assert len(collected) == len(set(collected)), (
f"duplicates found across pages: {len(collected)} items but only "
f"{len(set(collected))} unique ids"
)
assert set(collected) == expected_ids, (
f"missing: {expected_ids - set(collected)}, unexpected: {set(collected) - expected_ids}"
)
Verification harness. Monkeypatching requests.get with a fixture stands in for the real API without a network call, and lets both the buggy and fixed cases actually run and print their own result:
class FakeResp:
def __init__(self, data):
self._data = data
def json(self):
return self._data
def build_fixture(duplicate_bug: bool):
"""137 items across 3 pages; optionally leaks item 90 into a second page."""
all_items = [{"id": i} for i in range(1, 138)]
p1, p2, p3 = all_items[0:50], all_items[50:100], all_items[100:137]
if duplicate_bug:
p3 = [{"id": 90}] + p3 # item 90 duplicated across two consecutive pages
pages = {
None: {"items": p1, "next_page": "tok2"},
"tok2": {"items": p2, "next_page": "tok3"},
"tok3": {"items": p3, "next_page": None},
}
def fake_get(url, params=None):
return FakeResp(pages[params.get("next_page") if params else None])
return fake_get
expected_ids = set(range(1, 138))
requests.get = build_fixture(duplicate_bug=True)
try:
test_pagination_completeness_no_duplicates("http://fake", expected_ids, use_tokens=True)
except AssertionError as e:
print(f"AssertionError: {e}")
requests.get = build_fixture(duplicate_bug=False)
collected = fetch_all_items("http://fake", use_tokens=True)
test_pagination_completeness_no_duplicates("http://fake", expected_ids, use_tokens=True)
print(f"test passed: {len(set(collected))}/{len(expected_ids)} unique items collected, expected set matched exactly")
Against the buggy fixture (item 90 duplicated across two consecutive pages):
AssertionError: duplicates found across pages: 138 items but only 137 unique ids
And against the same fixture with the duplication bug fixed:
test passed: 137/137 unique items collected, expected set matched exactly
Trade-offs and pitfalls
The subtlety in the collection step above is worth calling out explicitly because it's an easy bug to write into the TEST itself: an earlier version of this exact function deduplicated ids as it collected them (only appending an id the first time it was seen), which meant the "duplicates found" assertion could never fire, no matter how badly the API was actually duplicating items across pages, because the dedup step silently absorbed the duplication before the check ever ran. A completeness test needs to collect the raw, undeduplicated data first and treat deduplication as part of the ASSERTION, not part of the collection.
A completeness test like this also needs a known, fixed dataset to compare against: testing against a live, changing dataset makes "missing" and "unexpected" ambiguous, since an item could legitimately have been added or removed between the start and end of the pagination walk rather than being a real bug. In CI, this argues for seeding a deterministic dataset before the test runs rather than pointing the test at shared, mutable data.
You need to choose between Postman/Newman, REST-Assured, and a lightweight HTTP client with custom assertions for building an automated API test suite for a mixed-language team. What would push you toward each option, and how do they compare on maintainability, debugging, and how easily new team members can contribute?
Sample Answer
Direct answer
Postman/Newman, REST-Assured, and a lightweight HTTP client each fit a different point on the trade-off between accessibility and code-native integration: Postman is the easiest to start in and the most approachable for non-programmers, REST-Assured gives the strongest fit for a Java-heavy team that wants tests as real code, and a lightweight client is the leanest option when you don't want a testing-specific dependency at all.
Structured elaboration
Maintainability. REST-Assured tests live as real code in the same repository and language as the service under test, so they get code review, IDE refactoring support, and version control diffing the same way any other code does. Postman collections are JSON files that CAN be version-controlled, but editing them productively usually happens through Postman's own UI, which makes diffs noisier and large-scale refactoring (renaming a variable used across fifty requests, say) more manual. A lightweight client's tests are whatever plain code you write, maintainability is entirely a function of how disciplined the team is about structuring that code, since the tool itself gives you no scaffolding either way.
Parametrization. All three support it, but differently. Postman uses environment and collection variables, straightforward for simple substitution, awkward for anything more programmatic (looping over a generated dataset, for instance). REST-Assured and a lightweight client both get this for free from the host language's normal data structures and control flow.
Debugging. Postman's UI shows you the request and response interactively as you build a test, which is genuinely fast for exploring an unfamiliar API. REST-Assured and lightweight-client tests debug the way any code does: breakpoints, stack traces, print statements, less immediately visual, but more powerful once you're deep into a complex assertion chain.
Team collaboration and onboarding. Postman is approachable for team members who aren't strong programmers, a manual QA engineer can build and run a Postman collection without writing code. REST-Assured and lightweight-client suites require everyone touching them to be comfortable in that language, a real constraint on a mixed-skill team, but not a real constraint if the team is uniformly engineers.
Integration with CI and language-native test runners. REST-Assured and a lightweight client integrate natively with whatever your codebase already uses for CI reporting (JUnit, pytest, whatever the ecosystem's runner is), so failures show up in the same place as every other test result. Postman needs Newman as a bridge to run in CI at all, which works well but is an extra moving part compared to a suite that's just... normal code in the normal test runner.
Trade-offs and pitfalls
Constraint-driven choice. For a mixed-language team where not everyone shares a common backend language, Postman's language-agnostic UI is often the pragmatic choice, everyone can contribute regardless of what they code in day to day, even if it costs some of the maintainability and CI-native integration REST-Assured would offer a single-language team. A common workflow that gets the best of both: start exploring an unfamiliar API in Postman (fast, interactive, low ceremony), and once the important test cases are understood, port the ones that need to be a durable, CI-enforced part of the suite into REST-Assured or a lightweight client, keeping Postman for ad hoc exploratory work rather than as the system of record for regression coverage.
An API might be protected by Basic Auth, an API key in a header, an OAuth 2.0 bearer token, or a client TLS certificate, depending on the endpoint. For each mechanism, how would you actually test that it's enforced correctly, and when would you mock the authentication step rather than performing a real end-to-end login flow in your test?
Sample Answer
Direct answer
Basic Auth and static API keys are simplest to test end-to-end since there's no token lifecycle involved. OAuth 2.0 bearer tokens and client TLS certificates are usually more practical to test with a mocked or pre-generated credential most of the time, reserving a real end-to-end login flow for a small number of specific tests, because the full flow is slower, harder to make deterministic, and often depends on infrastructure your test suite doesn't want to own.
Structured elaboration
Basic Auth. Test with a valid username/password pair, an invalid one, and a missing Authorization header, asserting 200, 401, and 401 respectively. Because there's no token to generate or expire, this is the simplest mechanism to test fully end-to-end every time, there's little benefit to mocking it.
API keys in headers. Similarly simple: a valid key returns success, an invalid or revoked key returns 401, a missing key returns 401. The main thing worth testing beyond that is whether the key is scoped (some keys can only call certain endpoints), which needs its own test asserting a valid-but-wrongly-scoped key returns 403, not 401, since 403 correctly signals "I know who you are, you're just not allowed to do this."
OAuth 2.0 bearer tokens. This is where mocking versus real end-to-end matters most. A full OAuth flow (redirect to an authorization server, user consents, authorization code exchanged for a token) is slow, involves a browser or a simulated one, and depends on a real or sandboxed identity provider being available. For the large majority of API tests, that's the wrong tool: instead, pre-generate a valid token (either from a test identity provider you control, or a token minted directly with a known signing key in a test environment) and use it directly as a fixture. Reserve actually driving the full OAuth flow for a handful of tests specifically about the LOGIN flow itself, not for every test that merely needs to be authenticated to check something else.
Client TLS certificates. Testing this properly generally needs an actual TLS handshake with a client certificate, since a mocked "you have a valid cert" check doesn't exercise the real validation logic: certificate chain validation (confirming the cert was actually issued by a certificate authority the server trusts, not just any self-signed cert), expiry checking (confirming the cert falls within its stated validity window), and revocation checking (confirming the cert hasn't been invalidated early by its issuer, typically via a certificate revocation list or OCSP, the Online Certificate Status Protocol, before its normal expiry date). This usually means a small number of tests running against a real TLS-terminating endpoint configured with a test certificate authority, rather than being mockable the way a bearer token is.
Trade-offs and pitfalls
Mock vs. full flow, decided by what's actually being tested. If the test's purpose is "does this endpoint correctly reject an expired token," you don't need a real OAuth flow to produce an expired token, you can mint one directly with an expired timestamp using the same signing key your test environment trusts, which is faster and more deterministic than trying to wait for a real token to expire. If the test's purpose is the login flow itself, redirect handling, consent screen, code exchange, then only a real (or realistically simulated) flow actually tests it; a mock would just assume the thing being tested works.
A common mistake is mocking authentication so completely that a real, deployed misconfiguration (a wrong signing key, an expired certificate, a misregistered redirect URI) would sail through every test undetected, because none of the tests ever exercised the real mechanism at all. The right balance keeps a small number of true end-to-end auth-flow tests as a canary, backed by a much larger number of tests that use pre-generated credentials to test everything else quickly.
Unlock Full Question Bank
Get access to all 29 API and Contract Testing interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.