API Contract Testing: Catching Breaking Changes
A breaking change in an API is one of the most disruptive things that can happen to a team that didn't see it coming. A field gets renamed, a required parameter gets added, a response type quietly shifts from a string to an integer — and suddenly every downstream consumer is broken. The frustrating part is that these changes often pass unit tests and even basic integration tests without a single failure, because those tests weren't designed to verify the contract between producer and consumer. That's the gap contract testing is built to fill.
Contract testing is the practice of asserting that an API continues to honour the agreed shape, structure, and behaviour that its consumers depend on. It's not about testing business logic — it's about testing the interface itself. In practice, that means validating field names, data types, required vs. optional fields, status codes, and the presence or absence of specific response keys. When I talk to teams who are already doing solid real-world API testing, contract tests are almost always the missing layer they wish they'd added earlier.
In this article I'll walk through what contract testing actually looks like in a Python-based test suite, the most common mistakes that let breaking changes slip through, and how to structure your checks so they catch regressions automatically rather than waiting for a developer or customer to report them.
Smarter API Test Automation — Python, Behave, VS Code, AI with GitHub Copilot & CI/CD Pipelines. Complete in a Weekend!
What a Breaking Change Actually Looks Like in an API Response
Before writing a single test, it helps to be precise about what you're protecting against. "Breaking change" is a broad term, so let's be concrete. Here are the categories I see cause the most real-world incidents:
- Renamed fields —
user_idbecomesuserIdorid. Your consumer code breaks silently if it doesn't get aKeyError. - Type changes — a field that was always a string now returns an integer, or a single object becomes a list. Downstream parsing blows up in unpredictable ways.
- Removed fields — a field that was documented as optional gets dropped entirely. Consumers that relied on it get
Noneor a missing-key error. - New required fields on requests — the API now demands a field your client never sends, turning previously valid calls into
400 Bad Requestresponses. - Status code changes — an endpoint that returned
200for an empty result set now returns404. Any consumer branching on status code is now broken.
A simple example: imagine a GET /users/{id} endpoint that previously returned this:
{
"user_id": 42,
"full_name": "Ada Lovelace",
"email": "ada@example.com"
}
And after a refactor it returns this:
{
"id": 42,
"name": "Ada Lovelace",
"email": "ada@example.com"
}
Two fields renamed. Every consumer that reads response["user_id"] or response["full_name"] is now broken. A functional test that only checked the status code and that email was present would pass right through this without complaint.
This is why contract tests need to be explicit about structure, not just behaviour. The test isn't asking "does this endpoint work?" — it's asking "does this endpoint still look exactly the way my consumers expect it to look?"
Writing Contract Assertions in Python: Schema Validation That Actually Catches Regressions
The most reliable way I've found to enforce a contract in a Python test suite is JSON Schema validation. It's explicit, it's version-controllable, and when it fails, the error message tells you exactly which field violated which rule. The jsonschema library makes this straightforward to drop into pytest or Behave.
Here's a minimal but realistic example using pytest:
import requests
import jsonschema
import pytest
USER_SCHEMA = {
"type": "object",
"required": ["user_id", "full_name", "email"],
"properties": {
"user_id": {"type": "integer"},
"full_name": {"type": "string"},
"email": {"type": "string", "format": "email"}
},
"additionalProperties": False
}
def test_get_user_contract():
response = requests.get("https://api.example.com/users/42")
assert response.status_code == 200
jsonschema.validate(instance=response.json(), schema=USER_SCHEMA)
A few things worth calling out here. First, "required" enforces that those fields must be present — missing fields fail immediately. Second, "additionalProperties": False is a deliberate choice: it means if the API adds an undocumented field, the test fails. Some teams prefer to leave this out so that additive changes don't break the suite; that's a valid trade-off, but I lean toward keeping it strict for internal APIs where you control both sides, and relaxing it for third-party APIs you don't own. Third, type checking catches the string-to-integer class of bugs automatically.
If you're working in Behave, the same validation logic belongs in a step definition, and the schema itself should live in a separate fixture file or a schemas/ directory so it's easy to review and update in code review:
# features/steps/user_steps.py
from jsonschema import validate, ValidationError
from behave import then
@then('the response matches the user contract')
def step_validate_user_contract(context):
schema = context.schemas["user"]
try:
validate(instance=context.response.json(), schema=schema)
except ValidationError as e:
raise AssertionError(f"Contract violation: {e.message}")
Keeping schemas in files rather than inline in step definitions means your QA team and developers can review contract changes the same way they review code — through pull requests. That's where the real value is: the schema becomes living documentation of the agreed interface, and any change to it is visible and deliberate. If you're newer to structuring test suites at this level, the fundamentals of API testing are worth revisiting before going deep on schema tooling.
One more pattern worth adopting: parametrize your contract tests across multiple response fixtures (a normal user, an admin user, a user with optional fields missing) so you're validating the schema against the full range of shapes the API might return, not just the happy-path example.
Fitting Contract Tests Into CI So Breaking Changes Never Reach Main
Writing contract tests is only half the job. The other half is making sure they actually run at the right point in your pipeline. I've seen teams write solid schema validation tests and then only run them manually before releases — which means a breaking change introduced on a Tuesday gets discovered by a frustrated consumer on Thursday. Contract tests need to run on every pull request, against a real running instance of the API.
The practical setup I recommend is a dedicated contract test stage in CI that runs after your unit and integration tests but before any deployment to a shared environment. The sequence looks like this:
- Spin up the API (or use a deployed preview environment).
- Run contract tests against it.
- If any schema assertion fails, block the merge.
In a GitHub Actions workflow, that translates to a job that depends on your build job, calls your test command, and reports results back to the pull request. Something like:
jobs:
contract-tests:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run contract tests
run: pytest tests/contract/ -v
Two things that trip teams up here. First, environment targeting: make sure your contract tests point at the environment that reflects the PR's code, not a stale staging environment. A contract test that always passes because it's hitting last week's deployment is worse than no contract test at all — it builds false confidence. Second, versioning your schemas: when a contract change is intentional (the API team agreed to rename a field), the schema file should be updated in the same PR as the API change. That way the CI run on that PR validates the new contract, and consumers get notified through the diff that they need to update their code.
For teams building this kind of discipline from scratch, the path from "we have some API tests" to "we have a contract testing layer that blocks breaking changes" is a concrete skill progression worth investing in — becoming a skilled API automation tester means understanding not just how to write assertions but where in the delivery pipeline those assertions need to live. Contract testing is one of the clearest examples of a practice that pays back its setup cost almost immediately the first time it catches a rename or type change before it reaches production.