Every API break that made it to production on my team passed CI. All of them. The unit tests were green. The integration suite passed. The deploy went out, and then the Slack messages started.
The problem wasn’t that we weren’t testing. We were testing the wrong thing. Most CI pipelines verify that code runs. They don’t verify that the API contract between producer and consumer is still intact.
Why your existing tests won’t catch API breaks
Unit tests exercise your internal logic. They call your handler with mocked dependencies and assert on the response. Integration tests spin up your whole stack and hit real endpoints.
Both of these test the producer. They verify that your API behaves correctly when called the way you currently call it. They don’t test whether an external consumer, built against last week’s contract, can still talk to you.
The subtle breaks are the dangerous ones. You rename a field from user_id to userId because your linter complained. You change a 200 response to return a nested object instead of a flat one. You make a query parameter required that used to be optional. Your own tests are updated in the same PR, so everything passes. But every client in production breaks.
This is the difference between testing code and testing contracts.
What counts as a breaking API change
A breaking change is any modification that causes a correctly implemented client to fail. This isn’t about bugs. It’s about the promise your API made.
The most common breaks fall into three categories:
- Structural changes: Removing or renaming fields, changing types, altering nesting
- Behavioral changes: Making optional parameters required, changing pagination defaults, modifying error response shapes
- Lifecycle changes: Removing endpoints, changing URL paths, deprecating versions without warning
Some of these are obvious. Others are only obvious if you’re looking at the API from the consumer’s perspective. Most teams aren’t.
Layer 1: Catch structural breaks with OpenAPI diffing
OpenAPI specs describe the shape of your API. If you treat your spec as a contract, you can diff it against the previous version and flag breaking changes before merge.
Tools like oasdiff compare two OpenAPI documents and categorize changes as breaking, dangerous, or safe. A breaking change is something like removing a response field or changing a parameter from optional to required.
Here’s what it looks like in a GitHub Actions workflow:
# .github/workflows/api-contract.yml
name: API Contract Check
on:
pull_request:
paths:
- 'openapi.yaml'
jobs:
diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download oasdiff
run: |
curl -L https://github.com/Tufin/oasdiff/releases/latest/download/oasdiff_linux_amd64.tar.gz | tar xz
sudo mv oasdiff /usr/local/bin/
- name: Check for breaking changes
run: |
oasdiff breaking \
--base origin/main:openapi.yaml \
--revision openapi.yaml \
--fail-on WARN
This fails the build if the PR introduces a structural breaking change. It’s fast, deterministic, and catches the renames and removals that integration tests miss.
There’s a catch. OpenAPI diffing only sees the schema. It can’t tell if you changed the meaning of a field while keeping the type the same. A boolean active field that now means “email verified” instead of “account enabled” won’t show up in the diff. The type didn’t change. The contract did.
Layer 2: Verify consumer contracts with Pact
Consumer-driven contract testing flips the model. Instead of the API provider asserting its own correctness, the consumers define what they need. Those expectations become contracts that the provider must satisfy in CI.
Here’s how it works. Your frontend team writes a test that says: “When I call GET /users/123, I expect a 200 with a body containing user_id as a string.” Pact records this interaction and stores the contract.
On the provider side, your backend CI pulls down all consumer contracts and replays them against the current code. If a PR removes user_id or changes it to a number, the provider verification fails. Even if your own tests pass.
A minimal consumer test with Pact JS:
// consumer.spec.js
const { PactV3 } = require('@pact-foundation/pact');
const { expect } = require('chai');
const provider = new PactV3({
consumer: 'web-app',
provider: 'user-service',
dir: './pacts',
});
describe('GET /users/:id', () => {
it('returns the user', async () => {
await provider
.given('user exists')
.uponReceiving('a request for user 123')
.withRequest({
method: 'GET',
path: '/users/123',
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
user_id: '123',
email: 'alice@example.com',
},
});
await provider.executeTest(async (mockserver) => {
const user = await fetchUser(mockserver.url, '123');
expect(user.user_id).to.equal('123');
});
});
});
The provider verification in CI:
# .github/workflows/verify-contracts.yml
name: Verify Consumer Contracts
on: [pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify pacts
run: |
docker run --rm \
-v $(pwd)/pacts:/pacts \
pactfoundation/pact-cli \
verify-provider \
--provider-app-version ${{ github.sha }} \
--pact-broker-base-url https://your-pact-broker.io \
--provider user-service
This catches semantic breaks that schema diffing can’t see. If a consumer relies on a specific value format or error message, Pact will flag it.
The trade-offs most teams ignore
Neither approach is free.
OpenAPI diffing requires you to maintain an accurate spec. If your spec is generated from code annotations, it stays current automatically. If it’s hand-written, it will drift, and the diff becomes meaningless. Generated specs are better for this workflow.
Pact requires organizational discipline. Consumers must write contract tests. You need a broker to store and version contracts. When a consumer’s expectation is wrong, someone has to negotiate the change. This creates friction. That’s the point. The friction prevents silent breaks.
Running both is ideal but not always practical. If you have one or two critical consumers, Pact pays for itself quickly. If you have dozens of anonymous API consumers, OpenAPI diffing is the better starting point.
One more thing: neither tool catches performance regressions or authentication changes. A new endpoint that requires OAuth where none was needed before is a breaking change. Your diff tool might not flag it if the auth schema was already defined elsewhere. Keep your eyes open for the gaps.
FAQ
What is a breaking API change?
A breaking API change is any modification to an API that causes existing, correctly implemented clients to fail. This includes removing fields, changing types, making optional parameters required, or altering response status codes for existing endpoints.
How is API contract testing different from integration testing?
Integration tests verify that your system works as a whole. Contract tests verify that the API’s public interface matches what consumers expect. Integration tests can pass even when the contract breaks, if both sides of the system are updated in the same commit.
Can I use OpenAPI diffing without writing specs by hand?
Yes. Tools like oasdiff work with any OpenAPI document, including those generated from code annotations using libraries like SpringDoc, FastAPI, or drf-spectacular. Generated specs are often more reliable because they can’t drift from the implementation.
Do I need a Pact broker to use consumer-driven contracts?
For teams with more than a couple of services, yes. The broker stores contracts, tracks versions, and shows which consumers are affected by a proposed change. Without it, you’re passing contract files around manually, which breaks down quickly.
What about GraphQL?
GraphQL has different breaking change semantics. Removing a field is breaking, but adding one is safe. Tools like GraphQL Inspector provide schema diffing similar to OpenAPI tools. Pact also supports GraphQL interactions.
Start with the schema diff
Start with OpenAPI diffing. It’s the lowest effort and catches the most common breaks. Add it to your CI today, even if your spec is imperfect. An imperfect check is better than no check.
Once you’ve caught a break in review that would have ruined someone’s weekend, you’ll understand why this matters.