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

LayerScopeCatchesCost
UnitOne functionLogic errors, edge casesMilliseconds
IntegrationHandler plus a real databaseSerialisation, SQL, transactionsSeconds
Schema validationResponse against OpenAPIUndocumented or missing fieldsMilliseconds
ContractProvider behaviour against consumer expectationsBreaking changes before releaseSeconds
End-to-endThe deployed systemWiring, config, authMinutes, 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.

Documenting APIs with OpenAPI Versioning and error shapes

Last refreshed 2026-09-18.