Testing and contract testing REST APIs
Test the failures as carefully as the happy path, and let a contract test catch the change that would break a client you do not control.
The layers and what each catches
| Layer | Scope | Catches | Cost |
|---|---|---|---|
| Unit | One function | Logic errors, edge cases | Milliseconds |
| Integration | Handler plus a real database | Serialisation, SQL, transactions | Seconds |
| Schema validation | Response against OpenAPI | Undocumented or missing fields | Milliseconds |
| Contract | Provider behaviour against consumer expectations | Breaking changes before release | Seconds |
| End-to-end | The deployed system | Wiring, config, auth | Minutes, and flaky |
import pytest, jsonschema, yaml
from app import create_app
from app.store import reset_db
SPEC = yaml.safe_load(open("openapi.yaml"))
@pytest.fixture()
def client():
reset_db()
app = create_app({"TESTING": True})
with app.test_client() as c:
yield c
def validate(path: str, method: str, response) -> None:
schema = (SPEC["paths"][path][method]["responses"]
[str(response.status_code)]["content"]["application/json"]["schema"])
# Resolve refs before validating; a bare $ref is not a schema on its own
resolved = resolve_refs(schema, SPEC)
jsonschema.validate(response.get_json(), resolved)
def test_get_order_matches_the_spec(client):
r = client.get("/orders/1042")
assert r.status_code == 200
validate("/orders/{id}", "get", r)
def test_unknown_order_is_a_problem_document(client):
r = client.get("/orders/0000")
assert r.status_code == 404
body = r.get_json()
assert body["status"] == 404
assert r.headers["Content-Type"].startswith("application/problem+json")- Assert on status codes and headers, not only on the body. A correct body with a 200 where it should be 404 is still a bug.
- Test the failure paths deliberately: a missing field, an oversized payload, an expired token, a duplicate idempotency key.
- Freeze time where a response contains a timestamp, so the test does not fail at midnight.
- Validate responses against the published schema. That is the cheapest way to catch a field you renamed by accident.
Contract testing in practice
# A consumer-driven contract, abbreviated
consumer: reporting-service
provider: orders-api
interactions:
- description: an order that exists
request: { method: GET, path: /orders/1042 }
response:
status: 200
headers: { Content-Type: "application/json" }
body: { id: "1042", status: "pending", total: { amount: 2500, currency: "GBP" } }
- description: an order that does not exist
request: { method: GET, path: /orders/9999 }
response: { status: 404 }# The provider verifies every consumer's contracts before it can ship
pact-verifier --provider-base-url=http://localhost:8080 \
--pact-url=./pacts/reporting-service-orders-api.json
# And a breaking-change check on the specification itself
oasdiff breaking --base main:openapi.yaml --revision HEAD:openapi.yaml⚠️
A contract test that asserts the entire response body will fail on every additive change, and teams respond by deleting the assertions. Assert only the fields the consumer actually reads, and let additional fields pass through unremarked.
FAQ
Do I need contract testing if I validate against OpenAPI?
They answer different questions. Schema validation proves you match your published contract; contract testing proves your published contract still satisfies what a specific consumer relies on.
How many end-to-end tests should I have?
A handful, covering the paths that only exist in a deployed system: authentication, routing, configuration and one full happy path. Their value is breadth of wiring, not depth of logic.
Related
Documenting APIs with OpenAPI Versioning and error shapes
Last refreshed 2026-09-18.