Small teams ship fast, and that speed is often powered by APIs. The same speed can also create a particular kind of failure: everything works in isolation, but one change quietly breaks another system that depends on it.
API contract testing is a practical middle ground between “we hope nothing breaks” and a heavyweight integration test environment that nobody maintains. The goal is not perfection. The goal is to catch breaking changes early, with tests that are cheap enough to run on every pull request.
This post lays out a simple pattern for contract tests that works whether your services are internal, customer-facing, or part of an automation workflow. It emphasizes clarity, small scope, and routines that fit a small team.
What an API contract is (and what it is not)
An API contract is the set of promises a service makes to its consumers. It includes how to call an endpoint, what inputs are required, what outputs look like, and what error behaviors to expect.
A contract is not your entire OpenAPI file, your whole database schema, or the complete set of internal behaviors. A good contract focuses on the parts that consumers actually rely on. That distinction matters because overly broad contracts become brittle and are quickly ignored.
Contract surface area: the part that can break others
When deciding what “counts” as contract, prioritize things that cause real breakage:
- Field presence and type: removing a field, changing a string to a number, or changing nullability.
- Semantics: a field still exists, but its meaning changes (for example,
statusvalues change). - HTTP behaviors: status codes, redirects, authentication requirements, rate limiting behavior.
- Ordering and pagination: what “next page” means, stable sort order, idempotency expectations.
The minimum contract worth testing
If you only test one thing, test the “happy path” response structure for each endpoint that another system depends on. If you can test two things, add “one important failure mode” as well.
Here is a compact contract description that is often enough to prevent accidental breakage:
- Endpoint method and path (including required query parameters)
- Required request fields (and minimal validation rules)
- Response status code(s)
- Required response fields and their types
- Allowed enum values for critical fields
- One error response shape (for example, validation errors)
Keep it small and explicit. A contract should read like a checklist of “things that must not change without coordination.”
{
"endpoint": "POST /v1/orders",
"request": { "required": ["customerId","items[]"], "types": {"customerId":"string","items[]":"array"} },
"response": { "201": { "required": ["orderId","status"], "types": {"orderId":"string","status":"enum(PENDING|PAID)"} } },
"errors": { "400": { "required": ["error","message"], "types": {"error":"string","message":"string"} } }
}
A lightweight contract test approach
You can implement contract tests without introducing a large platform. The approach below is intentionally simple: each consumer defines what it needs, and the provider proves it still meets those needs.
The basic workflow
- Write contracts from the consumer perspective. The consumer describes the minimal request it will send and the minimal response it must be able to parse.
- Store contracts next to the consumer. This keeps contracts honest. If the consumer code changes, the contract evolves with it.
- Run provider verification in CI. The provider pulls the latest contracts and runs tests against a local instance (or a dedicated test environment).
- Block merges on contract failures. If you do not enforce the result, the tests become “advice” and will be ignored under time pressure.
The key idea is that the provider should not guess what consumers need. Consumers declare it, and providers verify it. This naturally pushes teams toward stable interfaces and small, intentional breaking changes.
What to test in practice
For each contract, your provider verification can be straightforward:
- Make the contract’s sample request (or a close variant) against the provider.
- Assert the response status code matches.
- Validate required fields exist and have expected types.
- Validate enums or constrained values for critical fields.
- Optionally validate that error responses include a predictable, parseable structure.
This is not a full schema validator. It is a guardrail against accidental changes that force consumers into emergency fixes.
Key Takeaways
- Keep contracts small: test only what consumers truly depend on.
- Let consumers define contracts, then have providers verify them in CI.
- Include one happy path and one important failure mode per endpoint.
- Enforce the result by blocking merges on contract failures.
- Version or add fields instead of mutating meanings in place.
Real-world example: a two-service checkout flow
Imagine a small ecommerce company with two internal services:
- Checkout Service creates orders and charges customers.
- Fulfillment Service reads orders and triggers packing and shipping.
Fulfillment depends on GET /v1/orders/{id} returning status, items, and shippingAddress. A developer on Checkout refactors the order model and renames shippingAddress to deliveryAddress because it feels more accurate.
Without contract tests, the provider’s unit tests pass, the endpoint still returns JSON, and the change ships. Fulfillment breaks at runtime when it tries to read shippingAddress, and you get an operational incident.
With contract tests, Fulfillment has a small consumer contract that declares shippingAddress as required. In Checkout’s CI, provider verification fails immediately after the rename. The team now has options that preserve stability:
- Keep
shippingAddressand adddeliveryAddressas an alias. - Introduce
/v2and migrate the consumer on a planned timeline. - Return both fields for a deprecation window, and only remove once consumers are updated.
The important part is not the field name. It is the earlier feedback loop, before the change reaches production.
Common mistakes
- Testing everything. If your contract asserts every field, every nested property, and every optional behavior, it will fail constantly and be ignored. Start with the minimum set that prevents breakage.
- Provider-owned contracts only. If providers define contracts in isolation, they often reflect how providers wish consumers worked, not how they actually work.
- No ownership for updates. When a breaking change is necessary, someone must own the migration plan, not just the code change.
- Ignoring error shapes. Consumers often depend on error structure for user messaging, retries, or alerting. A stable error envelope is a cheap win.
- Not running in CI. Contract tests that run “sometimes” are basically documentation. Useful, but not protective.
When not to do this
Contract tests are a strong default for service integrations, but they are not always the best next step. Consider skipping or delaying contract tests if:
- You have one service and no real consumers. Start with good unit tests and clear endpoint documentation. Add contracts when a second system depends on it.
- The API is deliberately experimental. For early prototypes, you may move faster with rapid iteration and manual coordination, then formalize once the interface stabilizes.
- You cannot commit to enforcing failures. If organizationally you cannot block merges, contract tests may create noise without improving outcomes.
If you are unsure, use a small pilot: pick one important endpoint, add one contract, and enforce it for a few weeks. Expand only if it proves valuable.
Rollout checklist you can copy
This checklist is designed for a small team and a small number of services. Treat it like an implementation plan you can complete in a sprint.
- Inventory your highest-risk integrations. List endpoints where breakage causes customer impact or manual work.
- Pick one consumer and one endpoint to start. Avoid boiling the ocean. Prove the workflow first.
- Write a minimal consumer contract. Happy path required fields, types, and one key error response.
- Add provider verification in CI. Stand up the provider in a test mode, run the contract checks, and fail the build on mismatch.
- Define a versioning rule. For example: add fields freely, never remove required fields without a new version, never change meaning in place.
- Agree on a deprecation habit. Set a default deprecation window and a standard way to communicate internally (for example, a ticket template).
- Expand endpoint by endpoint. Add contracts where they prevent real incidents, not where they look nice on a dashboard.
For more posts on building maintainable systems through routines and automation, browse the Archive.
Conclusion
API contract tests are a practical way for small teams to keep integrations stable while still moving quickly. The winning strategy is not heavy tooling. It is choosing a small contract surface area, writing contracts from the consumer perspective, and enforcing verification in CI.
Start with one endpoint that matters, make failures actionable, and build the habit. Stability tends to follow.
FAQ
Do I need OpenAPI to do contract testing?
No. OpenAPI can help, but the core value comes from testing the behaviors consumers rely on. A minimal contract can be expressed as fixtures and assertions even if you never generate a formal spec.
How is this different from unit tests on the API handler?
Unit tests protect internal logic. Contract tests protect the boundary between systems. They catch changes that look fine inside the provider but break consumers, like removing fields, changing enums, or altering error shapes.
What if I need to make a breaking change?
Make it explicit: introduce a new version or add new fields while keeping old ones for a deprecation window. Contract test failures are a signal to coordinate the migration, not a reason to avoid necessary evolution.
How many contracts should a small team maintain?
Enough to cover high-impact integrations. Many teams do well with a handful of contracts around core workflows, then expand only when a failure would be expensive or frequent.