Ever shipped a backend change that passed every test, only to wake up to a frontend on fire? The API still returned 200 OK — it just renamed user_name to username, and three consumers broke silently. Unit tests didn't catch it. Integration tests didn't catch it. This is exactly the gap contract testing fills.
What a contract actually is
A contract is a machine-readable agreement about the shape of requests and responses between a consumer (the client) and a provider (the API). Instead of testing the provider in isolation and hoping clients agree, the consumer declares what it needs, and the provider proves it still delivers.
In consumer-driven contract testing, the consumer owns the contract. The provider's job is to never break it without a conversation.
A minimal contract test
You don't need a heavyweight framework to start. Here's a contract expressed as a plain JSON schema that both sides can share.
{
"request": { "method": "GET", "path": "/users/42" },
"response": {
"status": 200,
"body": {
"type": "object",
"required": ["id", "username", "email"],
"properties": {
"id": { "type": "integer" },
"username": { "type": "string" },
"email": { "type": "string", "format": "email" }
}
}
}
}
The consumer verifies it can rely on these fields. The provider verifies its real output still satisfies them.
Verifying on the provider side
Run the contract against the live provider response in CI using Ajv, a fast JSON Schema validator:
import Ajv from "ajv";
import addFormats from "ajv-formats";
import contract from "./contracts/get-user.json" assert { type: "json" };
const ajv = addFormats(new Ajv({ allErrors: true }));
const validate = ajv.compile(contract.response.body);
test("GET /users/:id honors the consumer contract", async () => {
const res = await fetch("http://localhost:3000/users/42");
expect(res.status).toBe(contract.response.status);
const body = await res.json();
const ok = validate(body);
if (!ok) console.error(validate.errors);
expect(ok).toBe(true);
});
Now rename username back to user_name on the provider and this test fails before the change merges — not after a consumer pages you at 2 a.m.
Why this beats a shared staging environment
The classic alternative is end-to-end tests against a shared environment. They're slow, flaky, and only catch breakage after both services deploy together. Contract tests run in milliseconds, in isolation, and pin down responsibility: if the contract test goes red, the provider broke it.
Critically, contracts catch additive-but-breaking changes that loose typing hides — a field changing from integer to string, a required field going optional, an enum dropping a value. Your provider's own tests pass because the provider is internally consistent. The contract is the only thing watching the boundary.
Make breaking changes a deliberate act
The real payoff is workflow. Commit contracts to a repo both teams can see. Wire provider verification into the provider's pipeline. When a provider change violates a contract, CI fails and forces the question: do we version this, or coordinate the migration?
# .github/workflows/contracts.yml
name: contract-tests
on: [pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run start:provider &
- run: npx wait-on http://localhost:3000/health
- run: npm test -- contracts/
Breaking the contract becomes a conscious decision with a paper trail, not an accident.
Getting started without the ceremony
You can adopt this incrementally: start with your two or three most depended-on endpoints, write a schema for each, and fail the build when reality drifts. Add more as confidence grows. Tools like Pact add a broker and versioning once you outgrow plain schemas — but the principle is the same at every scale.
If you'd rather not hand-roll the schemas and CI glue, APIKumo lets you capture request/response shapes from your existing collections, keep them versioned alongside live docs, and run them as checks — so the contract stays in lockstep with the API your consumers actually call. Whichever route you take, the goal is the same: catch the break before your users do.
Top comments (0)