Skip to content
QSWEQB
hardDesignScenarioMidSeniorStaff

You own the API test suite for a service from scratch. How do you structure it, and what do you mock?

Separate contract tests, which check shape and backwards compatibility cheaply, from integration tests that exercise real behaviour across real dependencies. Make every test create and own its data so the suite is re-runnable, spend most of your negative cases on authorisation and malformed input.

7 min readUpdated 2026-07-26

What the interviewer is scoring

  • Whether the two layers are separated by what they can catch rather than by what tool runs them
  • Does the candidate make the suite re-runnable against a long-lived environment without manual reset
  • That negative testing goes past validation into authorisation between users
  • Whether idempotency is treated as a property of the API and of the suite, not only as a header
  • Can they state a specific failure that mocking would have hidden, and the compensating control for it

Answer

Two layers, divided by what they can catch

A contract test asks whether the interface still is what it claims to be: the status codes, the response shape, the required and optional fields, the types, the enumerated values, the error envelope. It should run in the provider's own pipeline in seconds, against the service with its dependencies stubbed or replaced, and it should fail on any change that would break a client — a field removed, a type narrowed, an enum member added to a response the client switches on, a previously optional request field made mandatory. Consumer-driven contract testing formalises this by having each consumer publish the subset of the interface it depends on, so the provider learns at build time that it is about to break someone rather than at deployment.

An integration test asks whether the behaviour is right when the real parts are connected: a real database with real constraints, real authentication, real neighbouring services. This is the only layer that catches the interesting class of failure, where every component is individually correct and the composition is not — a transaction that commits the order but not the inventory decrement, a serialisation that round-trips in unit tests and loses timezone on the way through the queue, a foreign key that rejects what the API accepts.

Keeping the split explicit matters because the two fail for different reasons and cost different amounts. A contract failure is unambiguous and cheap to diagnose; an integration failure needs someone to work out which of six moving parts moved. If you merge them into one suite of slow tests against a full environment you lose the fast, precise signal, and you end up with a hundred tests that all fail together whenever the environment is unwell.

Design the data before you design the tests

Almost every unmaintainable API suite got that way through test data, so decide the policy first. The default that survives is that each test creates what it needs through the API itself, keyed by something globally unique, and either cleans up afterwards or is written not to care.

### Every test gets its own subject. The suffix makes the test
### re-runnable and safe under parallel workers.
POST /v2/customers
{ "email": "qa+order-cap-7f3a91c4@example.com", "name": "Cap test" }

Three rules follow from that. Never assert on a global count or on the first page of an unfiltered list, because either assertion is a bet that no other test and no other person is using the environment. Never depend on a record you did not create, unless it is deliberately seeded reference data that nothing is allowed to mutate — currencies, tax rates, plan definitions — and even then treat it as read-only. And never let one test consume the output of another, because the moment the suite shards, the ordering you relied on disappears.

Some data cannot be created through the API: an account in a state only a nightly job produces, a customer whose contract began three years ago. For those, seed through a fixture at a lower level and label them clearly as environment prerequisites, because they are the part of the suite that will break silently when someone refreshes the database.

Idempotency, in both senses

The first sense is the API's. HTTP defines GET, HEAD, PUT, DELETE, OPTIONS and TRACE as idempotent, meaning repeating the request has the same effect on server state as making it once, while POST carries no such promise. Those properties are testable and worth testing directly: send the PUT twice and assert the resource is identical and no duplicate side effect occurred; send the DELETE twice and assert the second call behaves as specified, which is a design decision the team should have made explicitly rather than discovered. Where a POST creates something valuable, the usual protection is an idempotency key supplied by the client so that a retry after a timeout is recognised as a repeat. That is a widespread convention rather than a standard, so the test has to encode your API's version of it: same key and same body returns the original result and creates nothing new, and same key with a different body is rejected rather than quietly applied.

The second sense is the suite's. A suite that can only pass on a freshly built environment is a suite that stops running, because the environment is not fresh on the third Tuesday of a release. Aim for a suite you can run twice in a row and after someone else's run, which the unique-key discipline above mostly gives you for free.

Negative cases, ranked by what they protect

Negative testing is where API suites are thinnest, and the useful move is to rank rather than to enumerate. Highest value is authorisation, covered below. Next is input validation: missing required fields, wrong types, a string where a number is required, out-of-range numbers, an unknown enum value, an empty body, malformed JSON, and a request with the wrong Content-Type. Each should produce the documented error status and the documented error body, and the body matters as much as the code, since clients parse it. Malformed JSON and a semantically invalid payload are different situations and should not both be a bare 400 with no detail.

Then the protocol layer: an unsupported method on a valid path, an unacceptable media type, a payload over the size limit, and a conflicting write on a resource another request has already changed. Then the boundaries of paging and filtering — page zero, a page beyond the end, an oversized page size, a sort field that does not exist, and a filter that legitimately matches nothing, which must return an empty collection rather than a 404. Finally, behaviour under rate limiting, which is worth one test asserting that exceeding the limit returns the documented status with retry guidance rather than a generic failure.

For each of these, assert the status code, the error shape, and that nothing was created. A validation test that only checks for a 400 will pass happily against an endpoint that returns 400 and writes the record anyway.

The 200 that should have been a 403

Here is the case that separates a suite from a checklist. Nearly everyone tests that an endpoint rejects an absent or expired token, and nearly nobody tests that user A cannot read user B's resource by putting B's identifier in the path. Authentication is per-endpoint and easy to get right in one place; authorisation is per-object and enforced in the handler, which is why broken object-level authorisation sits at the top of the OWASP API Security Top 10 and why it is the defect most likely to be shipped by a team with a green pipeline.

So the fixture set needs at least two users in different tenants and one privileged role, and the pattern is mechanical: for every resource-scoped endpoint, create the object as A, then call it as B and assert the failure. Assert on the exact status too, because 404 and 403 are different disclosures — a 403 on an object B cannot see confirms that the object exists, which is sometimes acceptable and sometimes a leak, and the API needs a stated policy either way. The same reasoning applies to fields: a response that hides an internal cost field from a customer role is only tested if you assert the field is absent, not merely that the request succeeded.

Where mocking pays and where it lies to you

Mock at the boundary you do not own. A payment provider's sandbox, an identity provider, a courier's tracking API, an SMS gateway: these are slow, occasionally down, sometimes rate-limited, and never the behaviour under test when you are checking your own order logic. Stubbing them makes the suite deterministic and fast, and it unlocks the tests you could not otherwise write at all — a downstream timeout, a 503 during retry, a truncated response body, a response that is valid JSON with a field missing. Those failure paths are where production incidents live, and injecting them is much easier than provoking them.

The lie is drift. A mock encodes your current belief about someone else's interface, and it keeps passing long after that belief stops being true, so the suite is green precisely when the integration is broken. It gets worse when the mock is optimistic in a way the real service is not, returning immediately where the real one takes two seconds, accepting a field ordering that the real parser rejects, or reporting errors in a shape your error handling was written against rather than the shape actually sent.

Two compensating controls are enough. Derive stubs from the provider's published schema where one exists and validate them against it in CI, so a schema change breaks the mock rather than passing through it. And keep a small suite that runs against the real dependency on a schedule, outside the merge path so a provider outage does not block your team, with its failures routed to someone. That job exists to answer one question — is our idea of this integration still correct — and it is the only thing in the suite that can.

The other half of the guidance is where not to mock. Stubbing your own database or your own neighbouring service inside an integration test removes the exact thing the layer was built to catch. If a test mocks the repository, it is a unit test with extra scaffolding, and it will pass through a schema migration that breaks every real query.

Contract tests tell you the interface still fits; integration tests tell you the system still works; and the mocks that make the suite fast are the reason you also need something slow, scheduled, and honest about the outside world.

Likely follow-ups

  • Your consumer contract passes and the integration fails. What did each layer actually tell you?
  • How do you test that a change is backwards compatible for a client you cannot see?
  • The service publishes events as well as responding to requests. What does that add to the suite?
  • Where would you put a test for a 30-second downstream timeout, and how would you produce one?

Related questions

Further reading

api-testingcontract-testingtest-dataidempotencymocking