Our v2 API broke v1 clients in production on a Wednesday because nobody had tested the case where a v1 client received a v2-shaped response after a load-balancer routing rule was tweaked.*
The outage lasted less than an hour.
The damage lasted much longer.
A mobile application started failing silently because it expected a field named:
{
"customerName": "John Smith"
}
but suddenly received:
{
"fullName": "John Smith",
"customerStatus": "Active"
}
Nothing was technically wrong with the v2 API.
The issue was that some v1 clients unexpectedly started receiving v2 responses.
The routing change looked harmless during deployment.
No tests covered that scenario.
This experience taught us an important lesson:
Versioning an API isn't just about creating /v2.
It's about ensuring every version continues behaving exactly as its consumers expect.
Most teams write happy-path tests for each version independently. Very few teams write tests that validate interactions between versions, backward compatibility guarantees, and accidental cross-version behavior.
These are the three categories of API versioning tests that almost every team misses.
Why Versioning Tests Matter More Than Ever
Modern APIs evolve constantly.
New versions introduce:
- Additional fields
- Renamed properties
- New authentication requirements
- Performance improvements
- Business logic changes
None of these are inherently bad.
The risk comes from assuming:
"Since v2 works, v1 must still work too."
That assumption causes many production incidents.
Consumers don't upgrade immediately.
Some clients may continue using older versions for months or years.
A single breaking change can impact:
- Mobile applications
- Partner integrations
- Third-party SDKs
- Internal microservices
- Reporting systems
This is why API version testing requires more than endpoint-level validation.
It requires contract validation across versions.
1. The Dual-Version Contract Test
This is the most valuable test we added after our routing incident.
The concept is simple.
Every request that exists in both versions should be tested against both versions simultaneously.
For example:
GET /customers/123
is executed against:
/v1/customers/123
/v2/customers/123
The goal isn't necessarily to make responses identical.
The goal is to validate contractual guarantees.
What the Test Checks
The test should answer:
- Does v1 still return every promised field?
- Are field types unchanged?
- Are required properties still present?
- Are deprecated properties still available?
- Are status codes consistent?
This catches situations where implementation changes accidentally impact previous versions.
The Cross-Version Routing Test
Now comes the scenario most teams never test.
What happens if:
v1 request → v2 service
or
v2 request → v1 service
This sounds unlikely.
In distributed systems, it happens more often than people expect.
Examples include:
- Load-balancer misconfigurations
- Canary deployments
- Service discovery issues
- Proxy rules
- API gateway changes
Your system should either:
- Continue functioning safely.
- Return a clear version error.
It should never fail unpredictably.
Example Test Matrix
| Request | Expected Response |
|---|---|
| v1 → v1 | Success |
| v2 → v2 | Success |
| v1 → v2 | Graceful handling |
| v2 → v1 | Graceful handling |
Most teams only test the first two rows.
The last two are where incidents happen.
2. Deprecated-Field Assertions (Yes, You Should Assert They Exist)
This recommendation often surprises people.
Many engineers believe deprecated fields should gradually disappear from tests.
In reality, the opposite is usually true.
If a field is marked as deprecated but remains part of the contract, your tests should explicitly assert its existence.
A Real Example
Version 1 returns:
{
"customerName": "John Smith"
}
Version 2 introduces:
{
"fullName": "John Smith"
}
The team marks:
customerName
as deprecated.
Everything seems fine.
Six months later, an engineer removes:
customerName
assuming nobody uses it anymore.
Unfortunately:
- Mobile apps still depend on it.
- A partner integration still parses it.
- Several reports still consume it.
Production breaks.
The Better Approach
Your test should explicitly verify:
expect(response.customerName)
.toBeDefined();
Even though the field is deprecated.
Deprecation means:
"Planned for removal."
It does not mean:
"Safe to remove today."
Add Expiration Dates
The best practice is to combine assertions with metadata.
For example:
deprecated:
field: customerName
remove_after: 2027-01-01
Now tests can remind teams:
- Field still exists.
- Deprecation window is active.
- Removal date is approaching.
This creates a controlled migration process.
3. The Header-Based vs URL-Based Version Test Pattern
API versioning strategies vary significantly.
The two most common approaches are:
URL Versioning
/api/v1/orders
/api/v2/orders
Header Versioning
Accept: application/vnd.company.v1+json
Accept: application/vnd.company.v2+json
Both approaches require different testing strategies.
URL-Based Testing
This approach is relatively straightforward.
Test suites can easily target:
/v1/v2/v3
Contract separation is clear.
However, routing issues become important.
You need tests that verify:
- URL rewrites
- Gateway behavior
- Redirect handling
Header-Based Testing
Header-based versioning creates additional complexity.
The same endpoint:
GET /orders
can return completely different responses depending on headers.
Testing must validate:
- Missing version headers
- Invalid versions
- Default versions
- Unsupported versions
- Version negotiation behavior
A Test Pattern We Like
For every endpoint:
No header
v1 header
v2 header
invalid version header
future version header
This often reveals unexpected behavior.
Examples include:
- Silent fallbacks
- Incorrect defaults
- Cached responses
- Improper content negotiation
Snapshot Diffs Between v1 and v2 Responses
One of the simplest and most effective versioning techniques is snapshot comparison.
For a representative request:
GET /customers/123
Capture:
v1_response.json
v2_response.json
Then generate structural diffs.
Example Diff
{
- "customerName": "John Smith",
+ "fullName": "John Smith",
+ "customerStatus": "Active"
}
This provides immediate visibility into:
- Added fields
- Removed fields
- Type changes
- Structural changes
Why Snapshots Matter
Humans often miss small changes during code review.
Examples:
- "123"
+ 123
This appears minor.
For some consumers, it's a breaking change.
Snapshot testing catches these differences immediately.
Contract Snapshots Are Better Than Payload Snapshots
Avoid comparing exact values whenever possible.
Compare:
- Field names
- Types
- Required properties
- Structure
This reduces noise while preserving meaningful change detection.
The CI Gate That Catches Accidental Breaking Changes
The most effective versioning tests don't run only during releases.
They run continuously.
Every pull request should answer one question:
Does this change accidentally break a previous contract?
What the CI Gate Should Check
Schema Changes
- Required fields removed
- Data types changed
- Enum values changed
Response Changes
- Deprecated fields removed
- Status codes changed
- Error structures modified
Version Behavior
- Routing changes
- Header handling
- Content negotiation
Example CI Pipeline
Build
↓
Run version contract tests
↓
Generate snapshots
↓
Compare against baseline
↓
Fail if breaking changes detected
The process is simple.
The impact is enormous.
The Breaking Changes You Want to Catch
Examples include:
- customerId: integer
+ customerId: string
- status: Active
+ state: Active
- customerName
These changes may look harmless to developers.
For API consumers, they can be catastrophic.
Building a Sustainable Version Testing Strategy
A practical strategy usually includes four layers.
Layer 1: Endpoint Tests
Validate each version independently.
Layer 2: Contract Tests
Validate compatibility between versions.
Layer 3: Snapshot Diffs
Highlight structural changes.
Layer 4: CI Gates
Prevent accidental regressions from reaching production.
This layered approach dramatically reduces version-related incidents.
It also provides confidence when introducing new versions.
Why Teams Still Miss These Tests
Versioning issues often occur because teams think in terms of endpoints.
Consumers think in terms of contracts.
Those perspectives are different.
An endpoint can function perfectly while still breaking consumers.
The goal of version testing is not simply:
"Does the endpoint return 200?"
The goal is:
"Can every supported consumer continue working safely?"
That question requires broader testing.
Final Thoughts
The most painful API incidents I've seen were not caused by servers crashing or databases failing.
They were caused by small compatibility assumptions.
A renamed field.
A removed property.
A routing rule.
A header negotiation bug.
These changes rarely trigger alarms immediately.
Instead, they quietly break integrations that nobody remembered to test.
Strong backward compatibility tests and thoughtful deprecation testing dramatically reduce those risks.
Versioning isn't just about introducing new APIs.
It's about preserving trust with every client that still depends on older ones.
If your team is building or maintaining multiple API versions, adopting the contract-testing approach we use for versioned APIs can provide a much safer foundation for change:
Because in versioned systems, the bugs that hurt the most are usually the ones nobody thought to test.
Top comments (0)