API and contract testing
Most business rules live behind an API, not in the UI. Testing at the API layer is faster and more precise than driving a browser, and it covers clients the UI never exercises, such as mobile apps, partners and other services.
What to check on every endpoint
A useful API test asserts more than “it returned 200”. For each endpoint, cover:
- status codes: the right success code (
201for a create,204for an empty response) and the right error codes; - response shape: required fields present, correct types, no internal fields leaked;
- behaviour: the resource was actually stored, updated or deleted;
- auth:
401without credentials,403when the caller is authenticated but not allowed; - headers that clients depend on, such as
Content-Type, caching and pagination links.
Negative testing
Happy paths are the easy part. Real traffic includes missing fields, wrong types, oversized payloads, duplicate requests and callers poking at resources they don’t own. Negative testing deliberately sends these and checks that the API fails safely, with a clear error and no side effects.
it("hides other users' orders", async () => {
const order = await createOrder(alice, { total: 40 });
const res = await api(bob).get(`/orders/${order.id}`);
expect(res.status).toBe(404);
});
it("rejects a negative quantity without saving", async () => {
const res = await api(alice).post("/orders", {
items: [{ sku: "SKU-1", quantity: -2 }],
});
expect(res.status).toBe(400);
expect(res.body.errors[0].field).toBe("items[0].quantity");
expect(await countOrders(alice)).toBe(0);
});
The first test is an authorisation check. Returning 404 rather than 403 avoids confirming that the order exists, and broken object-level authorisation of this kind is one of the most common API security bugs.
Schema validation with OpenAPI
If your API is described by an OpenAPI document, use it as a test oracle. Validate every response in your API tests against the schema, so a renamed field or a number that became a string fails the build. Some tools go further and generate requests from the schema, including edge-case values, which is a cheap way to find crashes on unexpected input.
Validation only helps if the spec is the truth. Generate it from code, or check in CI that the implementation matches it; a hand-written spec that drifts is worse than none.
Contract testing
When services talk to each other, the classic way to check they still fit is an end-to-end environment with everything deployed. That is slow, and a failure rarely tells you which team broke what.
Contract testing checks each side of an integration separately against a shared agreement. In consumer-driven contracts, the consumer writes down the requests it makes and the parts of the response it actually uses. The provider then verifies it can satisfy every consumer’s contract.
Pact is a widely used tool for this:
- The consumer’s tests run against a Pact mock server, which records each interaction into a contract file.
- The contract is published, usually to a Pact Broker.
- The provider’s CI replays those interactions against the real provider and checks the responses match.
- Before deploying either side, a check confirms the version being deployed is compatible with what is already running.
Because consumers only specify the fields they use, the provider can change everything else freely. Provider states (“given order 42 exists”) let the provider set up data for each interaction.
When contracts replace end-to-end tests
Contract tests answer “do these two services agree on the interface?” quickly and pinpoint the side that broke it. They do not test business flows across many services, and they say nothing about infrastructure, configuration or performance.
A reasonable split: contract tests for every service-to-service integration, API tests for each service’s own behaviour, and a thin end-to-end suite for the few journeys where the whole system must be seen working together.
Checklist
- Every endpoint has tests for success, validation errors,
401,403or404, and not-found. - Negative tests assert that nothing changed, not only the status code.
- Responses are validated against the OpenAPI schema in CI.
- Contracts describe only the fields the consumer really reads.
- Deploys are blocked when a contract check fails.